为什么epoll性能那么高?
epoll的数据结构
多种数据结构进行决策,epoll至少需要两个集合
-
所有fd的总集
-
就绪fd的集合
那么这个总集选用什么数据结构存储?我们知道,fd其底层对应一个TCB。那么也就是说key=fd,val=TCB,是一个典型的kv型数据结构,对于kv型数据结构我们可以使用以下三种进行存储。
-
hash
-
红黑树
-
b/b+tree
如果使用hash进行存储,其优点是查询速度很快,O(1)。
但是在我们调用epoll_create()的时候,hash底层的数组创建多大合适呢?如果我们有百万的fd,那么这个数组越大越好,如果我们仅仅十几个fd需要管理,在创建数组的时候,太大的空间就很浪费。而这个fd我们又不能预先知道有多少,所以hash是不合适的。
b/b+tree是多叉树,一个结点可以存多个key,主要是用于降低层高,用于磁盘索引的,所以在我们这个内存场景下也是不适合的。
在内存索引的场景下我们一般使用红黑树来作为首选的数据结构,首先红黑树的查找速度很快,O(log(N))。其次在调用epoll_create()的时候,只需要创建一个红黑树树根即可,无需浪费额外的空间。
那么就绪集合用什么数据结构呢,首先就绪集合不是以查找为主的,就绪集合的作用是将里面的元素拷贝给用户进行处理,所以集合里的元素没有优先级,那么就可以采用线性的数据结构,使用队列来存储,先进先出,先就绪的先处理。
所有fd的总集 -----> 红黑树
就绪fd的集合 -----> 队列
红黑树和就绪队列的关系
红黑树的结点和就绪队列的结点的同一个节点,所谓的加入就绪队列,就是将结点的前后指针联系到一起。所以就绪了不是将红黑树结点delete掉然后加入队列。他们是同一个结点,不需要delete。
struct epitem{
RB_ENTRY(epitem) rbn;
LIST_ENTRY(epitem) rdlink;
int rdy; //exist in list
int sockfd;
struct epoll_event event;
};
struct eventpoll {
ep_rb_tree rbr;
int rbcnt;
LIST_HEAD( ,epitem) rdlist;
int rdnum;
int waiting;
pthread_mutex_t mtx; //rbtree update
pthread_spinlock_t lock; //rdlist update
pthread_cond_t cond; //block for event
pthread_mutex_t cdmtx; //mutex for cond
};
协议栈如何与epoll模块通信
epoll的工作环境
应用程序只能通过三个api接口来操作epoll。当一个io准备就绪的时候,epoll是怎么知道io准备就绪了呢?是由协议栈将数据解析出来触发回调通知epoll的。也就是说可以把epoll的工作环境看出三部分,左边应用程序的api,中间的epoll,右边是协议栈的回调(协议栈当然不能直接操作epoll,中间的vfs在此不是重点,就直接省略vfs这一层)。
协议栈触发回调通知epoll的时机
socket有两类,一类是监听listenfd,一类是客户端clientfd。对于sockfd而言,我们一般比较关注EPOLLIN和EPOLLOUT这两个事件,所以如果是listenfd,我们通常的做法就是accept。对于clientfd来说,如果可读我们就recv,如果可写我们就send。
协议栈将数据解析出来触发回调通知epoll。epoll是怎么知道哪个io就绪了呢?我们从ip头可以解析出源ip,目的ip和协议,从tcp头可以解析出源端口和目的端口,此时五元组就凑齐了。socket fd --- < 源IP地址 , 源端口 , 目的IP地址 , 目的端口 , 协议 > 一个fd就是一个五元组,知道了fd,我们就能从红黑树中找到对应的结点。
那么这个回调函数做什么事情呢?我们传入fd和具体事件这两个参数,然后做下面两个操作
-
通过fd找到对应的结点
-
把结点加入到就绪队列
1、协议栈中,在三次握手完成之后,会往全连接队列中添加一个TCB结点,然后触发一个回调函数,通知到epoll里面有个EPOLLIN事件
2、客户端发送一个数据包,协议栈接收后回复ACK,之后触发一个回调函数,通知到epoll里面有个EPOLLIN事件
3、每个连接的TCB里面都有一个sendbuf,在对端接收到数据并返回ACK以后,sendbuf就可以将这部分确认接收的数据清空,此时sendbuf里面就有剩余空间,此时触发一个回调函数,通知到epoll里面有个EPOLLOUT事件
4、当对端发送close,在接收到fin后回复ACK,此时会调用回调函数,通知到epoll有个EPOLLIN事件
5、当接收到rst标志位的时候,回复ack之后也会触发回调函数,通知epoll有一个EPOLLERR事件
通知的时机总结
一个有5个通知的地方
-
三次握手完成之后
-
接收数据回复ACK之后
-
发送数据收到ACK之后
-
接收FIN回复ACK之后
-
接收RST回复ACK之后
从回调机制看epoll 与 select/poll的区别
由于select和poll没有本质的区别,所以下面统一称为poll。
// poll跟select类似, 其实poll就是把select三个文件描述符集合变成一个集合了。
int select(int nfds, fd_set * readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
每次调用poll,都需要把总集fds拷贝到内核态,检测完之后,再有内核态拷贝的用户态,这就是poll。而epoll不是这样,epoll只要有新的io就调用epoll_ctl()加入到红黑树里面,一旦有触发就用epoll_wait()将有事件的结点带出来。
第一个区别:poll总是拷贝总集,如果有100w个fd,只有两三个就绪呢?这会造成大量资源浪费;而epoll总是将需要拷贝的东西进行拷贝,没有浪费。
第二个区别:我们从上面知道了epoll的事件都是由协议栈进行回调然后加入到就绪队列的,而poll呢?内核如何检测poll的io是否就绪?只能通过遍历的方法判断,所以poll检测io通过遍历的方法也是比较慢的。
所以两者的区别:
select/poll需要把总集copy到内核,而epoll不用。
实现原理上面,select/poll 需要循环遍历总集是否有就绪,而epoll是那个结点就绪了就加入就绪队列里面。
注意:poll不一定就比epoll慢,在io量小的情况下,poll是比epoll快的,而在大io量下,epoll绝对是有主导地位的。至于有多少个io才算多,其实也很难说,一般认为500或者1024为分界点。
epoll线程安全如何加锁
3个api做什么事情
epoll_create() ===》创建红黑树的根节点
epoll_ctl() ===》add,del,mod 增加、删除、修改结点
epoll_wait() ===》把就绪队列的结点copy到用户态放到events里面,跟recv函数很像
分析加锁
如果有3个线程同时操作epoll,有哪些地方需要加锁?我们用户层面一共就只有3个api可以使用:
如果同时调用 epoll_create() ,那就是创建三颗红黑树,没有涉及到资源竞争,没有关系。如果同时调用 epoll_ctl() ,对同一颗红黑树进行,增删改,这就涉及到资源竞争需要加锁了,此时我们对整棵树进行加锁。如果同时调用epoll_wait() ,其操作的是就绪队列,所以需要对就绪队列进行加锁。
我们要扣住epoll的工作环境,在应用程序调用 epoll_ctl() ,协议栈会不会有回调操作红黑树结点?调用epoll_wait() copy出来的时候,协议栈会不会操作操作红黑树结点加入就绪队列?综上所述:
epoll_ctl() 对红黑树加锁
epoll_wait()对就绪队列加锁
回调函数() 对红黑树加锁,对就绪队列加锁
那么红黑树加什么锁,就绪队列加什么锁呢?
对于红黑树这种节点比较多的时候,采用互斥锁来加锁。
就绪队列就跟生产者消费者一样,结点是从协议栈回调函数来生产的,消费是epoll_wait()来消费。那么对于队列而言,用自旋锁(对于队列而言,插入删除比较简单,cpu自旋等待比让出的成本更低,所以用自旋锁)。
ET与LT如何实现
ET边沿触发,只触发一次。LT水平触发,如果没有读完就一直触发。
代码如何实现ET和LT的效果呢?
水平触发和边沿触发代码只需要改一点点就能实现。从协议栈检测到接收数据,就调用一次回调,这就是ET,接收到数据,调用一次回调。而LT水平触发,检测到recvbuf里面有数据就调用回调。所以ET和LT就是在使用回调的次数上面的差异。
那么具体如何实现呢?
协议栈流程里面触发回调,是天然的符合ET只触发一次的。那么如果是LT,在recv之后,如果缓冲区还有数据那么加入到就绪队列。那么如果是LT,在send之后,如果缓冲区还有空间那么加入到就绪队列。那么这样就能实现LT了。
linux-2.6.24/fs/eventpoll.c文件中的ep_send_events函数
为什么用阻塞队列而不是List?
1、什么是阻塞队列?
阻塞队列是一种队列,阻塞队列是一种特殊的队列。阻塞队列是一种可以在多线程环境下使用,并且支持阻塞等待的队列。
线程 1 往阻塞队列中添加元素,当阻塞队列是满的,线程 1就会阻塞,直到队列不满
线程 2 从阻塞队列中移除元素,当阻塞队列是空的,线程 2 会阻塞,直到队列不空;
2、主要并发队列关系图
上图展示了 Queue 最主要的实现类,可以看出 Java 提供的线程安全的队列(也称为并发队列)分为阻塞队列和非阻塞队列两大类。
BlockingQueue 下面有 6 种最主要的阻塞队列实现,分别是
-
ArrayBlockingQueue
-
LinkedBlockingQueue
-
SynchronousQueue
-
DelayQueue
-
PriorityBlockingQueue
-
LinkedTransferQueue
非阻塞并发队列的典型例子是 ConcurrentLinkedQueue,这个类不会让线程阻塞,利用 CAS 保证了线程安全。
我们可以根据需要自由选取阻塞队列或者非阻塞队列来满足业务需求。
还有一个和 Queue 关系紧密的 Deque 接口,它继承了 Queue,如代码所示:
public interface Deque<E> extends Queue<E> {//...}
Deque 的意思是双端队列,音标是 [dek],是 double-ended-queue 的缩写,它从头和尾都能添加和删除元素;而普通的 Queue 只能从一端进入,另一端出去。这是 Deque 和 Queue 的不同之处,Deque 其他方面的性质都和 Queue 类似。
3、阻塞队列和 List、Set 的区别是什么?
阻塞队列和 List、Set 一样都继承自 Collection。
阻塞队列它和 List 的区别在于,List 可以在任意位置添加和删除元素。
而阻塞队列属于 Queue 队列的一种,Queue 只有两个操作:
-
把元素添加到队列末尾;
-
从队列头部取出元素。
常用的LinkedList就可以当队列使用,实现了Dequeue接口,还有ConcurrentLinkedQueue,他们都属于非阻塞队列。
4、阻塞队列和普通Queue 队列的区别是什么?
阻塞队列和一般的队列的区别就在于:
-
多线程环境支持,多个线程可以安全的访问队列
-
支持生产和消费等待,多个线程之间互相配合,在某些情况下会挂起线程,一旦条件满足,被挂起的线程又会自动被唤醒
-
当阻塞队列是空的,消费线程会阻塞,从队列中获取元素的操作将会被阻塞,直到队列不空
-
当阻塞队列是满的,生产线程就会阻塞,往队列里添加元素的操作将会被阻塞,直到队列不满
5、阻塞队列的作用
阻塞队列,也就是 BlockingQueue,它是一个接口,如代码所示:
public interface BlockingQueue<E> extends Queue<E>{...}
BlockingQueue 继承了 Queue 接口,是队列的一种。
Queue 和 BlockingQueue 都是在 Java 5 中加入的。
BlockingQueue 是线程安全的,在很多场景下都可以利用线程安全的队列来优雅地解决业务自身的线程安全问题。
比如说,使用生产者/消费者模式的时候,生产者只需要往队列里添加元素,而消费者只需要从队列里取出它们就可以了,如图所示:
在图中,左侧有三个生产者线程,它会把生产出来的结果放到中间的阻塞队列中,而右侧的三个消费者也会从阻塞队列中取出它所需要的内容并进行处理。因为阻塞队列是线程安全的,所以生产者和消费者都可以是多线程的,不会发生线程安全问题。
既然队列本身是线程安全的,队列可以安全地从一个线程向另外一个线程传递数据,所以生产者/消费者直接使用线程安全的队列就可以,而不需要自己去考虑更多的线程安全问题。这也就意味着,考虑锁等线程安全问题的重任从“你”转移到了“队列”上,降低了开发的难度和工作量。
同时,队列还能起到一个隔离的作用。
比如说开发一个银行转账的程序,那么生产者线程不需要关心具体的转账逻辑,只需要把转账任务,如账户和金额等信息放到队列中就可以,而不需要去关心银行这个类如何实现具体的转账业务。而作为银行这个类来讲,它会去从队列里取出来将要执行的具体的任务,再去通过自己的各种方法来完成本次转账。
这样就实现了具体任务与执行任务类之间的解耦,任务被放在了阻塞队列中,而负责放任务的线程是无法直接访问到银行具体实现转账操作的对象的,实现了隔离,提高了安全性。
6、阻塞队列的功能
阻塞队列区别于其他类型的队列的最主要的特点就是“阻塞”这两个字,所以下面重点介绍阻塞功能:阻塞功能使得生产者和消费者两端的能力得以平衡,当有任何一端速度过快时,阻塞队列便会把过快的速度给降下来。
7、阻塞队列的核心方法
方法类型 | 抛出异常 | 特殊值 | 阻塞 | 超时 |
---|---|---|---|---|
插入 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除 | remove() | poll() | take() | poll(time,unit) |
检查 | element | peek | 不可用 | 不可用 |
-
抛异常的方法 就是在插入满了之后,会报一个异常,remove一样,element是检查队头的元素或者是否为空。
-
特殊值的方法是在插入满之后返回值变成了false而不是一个异常,取出失败的时候返回null。
-
阻塞方法是在插入满之后把这个方法阻塞,一直等待队列空出来一个之后再进行加入,会出现一直等待,也可能出现饥饿现象。
-
超时方法的话,当阻塞队列满时,队列会阻塞生产者线程一定时间,超过限时后生产者线程会退出。
实现阻塞最重要的两个方法是 take 方法和 put 方法。
7.1 take 方法
take 方法的功能是获取并移除队列的头结点,通常在队列里有数据的时候是可以正常移除的。
可是一旦执行 take 方法的时候,队列里无数据,则阻塞,直到队列里有数据。一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。
过程如图所示:
7.2 put 方法
put 方法插入元素时,如果队列没有满,那就和普通的插入一样是正常的插入,但是如果队列已满,那么就无法继续插入,则阻塞,直到队列里有了空闲空间。
如果后续队列有了空闲空间,比如消费者消费了一个元素,那么此时队列就会解除阻塞状态,并把需要添加的数据添加到队列中。
put 过程如图所示:
以上过程中的阻塞和解除阻塞,都是 BlockingQueue 完成的,不需要我们自己处理。
7.3 是否有界(容量有多大)
此外,阻塞队列还有一个非常重要的属性,那就是容量的大小,分为有界和无界两种。
-
有的阻塞队列是无界的,无界队列意味着里面可以容纳非常多的元素,例如 LinkedBlockingQueue 的上限是 Integer.MAX_VALUE,约为 2 的 31 次方,是非常大的一个数,可以近似认为是无限容量,因为几乎无法把这个容量装满。
-
但是有的阻塞队列是有界的,例如 ArrayBlockingQueue 如果容量满了,也不会扩容,所以一旦满了就无法再往里放数据了。
10亿QPS的IM,如何实现?
1、超高并发:核心接口峰值达到10亿级QPS
企业微信作为一款Tob场景的聊天IM工具,用于工作场景的沟通,有着较为明显的高峰效应。
工作时间上午9:00~12:00,下午14:00~18:00,是聊天的高峰,消息量剧增。
核心接口,峰值达到10亿级QPS
以上仅仅是核心接口。
在消息的ID生产模块,也有1000WQps以上。
2、企业微信的业务场景分析
企业微信是一款收费产品,消息系统的稳定性、可靠性、安全性尤其重要。与TC的微信不同,而且针对toB场景的消息系统,需要支持更为复杂的业务场景。
针对toB场景的特有业务有:
-
1)消息鉴权:关系类型有群关系、同企业同事关系、好友关系、集团企业关系、圈子企业关系。收发消息双方需存在至少一种关系才允许发消息;
-
2)回执消息:每条消息都需记录已读和未读人员列表,涉及频繁的状态读写操作;
-
3)撤回消息:支持24小时的有效期撤回动作;
-
4)消息存储:云端存储时间跨度长,最长可支持180天消息存储,数百TB用户消息需优化,减少机器成本;
-
5)万人群聊:群人数上限可支持10000人,一条群消息就像一次小型的DDoS攻击;
-
6)微信互通:两个异构的im系统直接打通,可靠性和一致性尤其重要。
3、企业微信的架构分层
如上所示,整体架构分层如下。
1)接入层:
接收客户端的请求,根据类型转发到对应的安全分发层。
支持两种连接类型: 客户端可以通过长连或者短连接。
-
优先用长连接发起请求
-
如果长连失败,则选用短连重试。
2)安全分发层:
http类型的WEB服务,安全分发层 接收接入的的数据包,校验用户的session状态,进行安全校验,并用后台派发的秘钥去解包,如解密失败则拒绝请求。
如果 解密成功,则解密为明文包体, 然后进行分发,转发到后端逻辑层对应的svr。
3)逻辑层:
各种业务微服务和异步处理服务,使用自研的rpc框架(类似dubbo)通信。
逻辑进行数据整合和逻辑处理。
和外部系统的通信,通过http协议,包括微信互通、手机厂商的推送平台等。
4)存储层:
使用kv类型的存储组件:采用的是基于levelDB模型开发msgkv。
消息的key为 消息的全局有序编号,使用SeqSvr序列号生成器,保证派发的seq单调递增不回退。
消息的key也用于消息的收发协议
4、号段模式 seqsvr 消息序列号架构
微信服务器端为每一份需要与客户端同步的数据(例如消息)都会赋予一个唯一的、递增的序列号(sequence),作为这份数据的版本号。
在客户端与服务器端同步的时候,客户端会带上已经同步下去数据的最大版本号,后台会根据客户端最大版本号与服务器端的最大版本号,计算出需要同步的增量数据,返回给客户端。这样不仅保证了客户端与服务器端的数据同步的可靠性,同时也大幅减少了同步时的冗余数据。
这里不用乐观锁机制来生成版本号,而是使用了一个独立的seqsvr来处理序列号操作,
-
一方面因为业务有大量的sequence查询需求—查询已经分配出去的最后一个sequence,而基于seqsvr的查询操作可以做到非常轻量级,避免对存储层的大量IO查询操作;
-
另一方面微信用户的不同种类的数据存在不同的Key-Value系统中,使用统一的序列号有助于避免重复开发,同时业务逻辑可以很方便地判断一个用户的各类数据是否有更新。
从seqsvr申请的、用作数据版本号的sequence,具有两种基本的性质:
-
递增的64位整型变量
-
每个用户都有自己独立的64位sequence空间
这里用了每个用户独立的64位sequence的体系,而不是用一个全局的64位(或更高位)sequence,很大原因是全局唯一的sequence会有非常严重的申请互斥问题,不容易去实现一个高性能高可靠的架构。对微信业务来说,每个用户独立的64位sequence空间已经满足业务要求。
微信目前拥有数亿的活跃用户,每时每刻都会有海量sequence申请,这对seqsvr的设计也是个极大的挑战。那么,既要sequence可靠递增,又要能顶住海量的访问,要如何设计seqsvr的架构?
得先从seqsvr的架构原型说起。
号段模式 的分布式ID总体架构
什么是号段模式 的分布式ID总体架构?
不考虑号段模式的话,分布式ID应该是一个巨大的 64 位数组,而我们每一个微信用户,都在这个大数组里独占一格 8bytes 的空间,这个格子就放着用户已经分配出去的最后一个 sequence:cur_seq。
每个用户来申请 sequence 的时候,只需要将用户的 cur_seq+=1,保存回数组,并返回给用户。
图 1. 小明申请了一个 sequence,返回 101
任何一件看起来很简单的事,在海量的访问量下都会变得不简单。
前文提到,seqsvr 需要保证分配出去的 sequence 递增(数据可靠),还需要满足海量的访问量(每天接近万亿级别的访问)。
满足数据可靠的话,我们很容易想到把数据持久化到硬盘,但是按照目前每秒千万级的访问量(~10^7 QPS),基本没有任何硬盘系统能扛住。
后台架构设计很多时候是一门关于权衡的哲学,针对不同的场景去考虑能不能降低某方面的要求,以换取其它方面的提升。仔细考虑我们的需求,我们只要求递增,并没有要求连续,也就是说出现一大段跳跃是允许的(例如分配出的 sequence 序列:1,2,3,10,100,101)。于是我们实现了一个简单优雅的策略:
-
内存中储存最近一个分配出去的 sequence:cur_seq,以及分配上限:max_seq
-
分配 sequence 时,将 cur_seq++,同时与分配上限 max_seq 比较:如果 cur_seq > max_seq,将分配上限提升一个步长 max_seq += step,并持久化 max_seq
-
重启时,读出持久化的 max_seq,赋值给 cur_seq
图 2. 小明、小红、小白都各自申请了一个 sequence,但只有小白的 max_seq 增加了步长 100
这样通过增加一个预分配 sequence 的中间层,在保证 sequence 不回退的前提下,大幅地提升了分配 sequence 的性能。
实际应用中每次提升的步长为 10000,那么持久化的硬盘 IO 次数从之前~10^7 QPS 降低到~10^3 QPS,处于可接受范围。
在正常运作时分配出去的 sequence 是顺序递增的,只有在机器重启后,第一次分配的 sequence 会产生一个比较大的跳跃,跳跃大小取决于步长大小。
分号段共享存储架构
请求带来的硬盘 IO 问题解决了,可以支持服务平稳运行,但该模型还是存在一个问题:重启时要读取大量的 max_seq 数据加载到内存中。
我们可以简单计算下,以目前 uid(用户唯一 ID)上限 2^32 个、一个 max_seq 8bytes 的空间,数据大小一共为 32GB,从硬盘加载需要不少时间。
另一方面,出于数据可靠性的考虑,必然需要一个可靠存储系统来保存 max_seq 数据,重启时通过网络从该可靠存储系统加载数据。如果 max_seq 数据过大的话,会导致重启时在数据传输花费大量时间,造成一段时间不可服务。
为了解决这个问题,我们引入号段 Section 的概念,uid 相邻的一段用户属于一个号段,而同个号段内的用户共享一个 max_seq,这样大幅减少了 max_seq 数据的大小,同时也降低了 IO 次数。
图 3. 小明、小红、小白属于同个 Section,他们共用一个 max_seq。在每个人都申请一个 sequence 的时候,只有小白突破了 max_seq 上限,需要更新 max_seq 并持久化
目前 seqsvr 一个 Section 包含 10 万个 uid,max_seq 数据只有 300+KB,为我们实现从可靠存储系统读取 max_seq 数据重启打下基础。
5、消息收发模型架构
企业微信的消息收发模型采用了推拉结合架构,这种方式可靠性高,设计简单。
以下是消息推拉的时序图:
如上图所示,
第一步:后台推入接收方存储
发送方请求后台,把消息写入到接收方的存储,然后push通知接收方。
第二步:接收方收到通知后拉取消息
接受方收到push,主动上来后台收消息。
不重、不丢、及时触达,这三个是消息系统的核心指标:
-
1)实时触达:客户端通过与后台建立长连接,保证消息push的实时触达;
-
2)及时通知:如果客户端长连接不在,进程被kill了,利用手机厂商的推送平台,推送通知,或者直接拉起进程进行收消息;
-
3)消息可达:假如遇到消息洪峰,后台的push滞后,客户端有轮训机制进行兜底,保证消息可达;
-
4)消息防丢:为了防止消息丢失,只要后台逻辑层接收到请求,保证消息写到接收方的存储,失败则重试。如果请求在CGI层就失败,则返回给客户端出消息红点;
-
5)消息排重:客户端在弱网络的场景下,有可能请求已经成功写入存储,回包超时,导致客户端重试发起相同的消息,那么就造成消息重复。为了避免这种情况发生,每条消息都会生成唯一的appinfo,后台通过建立索引进行排重,相同的消息直接返回成功,保证存储只有一条。
6、群聊消息写扩散架构
读扩散与写扩散
所谓读扩散,就是: 存储一次,多次读。
所谓写扩散,就是:存储多次,各自读。
放到群聊的场景里说
读扩散,群里的每条消息只存储一份,群成员读取同一份数据。
优点:
-
数据实时性高;
-
写入逻辑简单;
-
节约存储空间。
缺点:
-
数据读取会存在热点问题;
-
需要维护离线群成员与未读消息的关系。
写扩散,群里发一条消息,给每个群成员都存储一份,群成员各自读自己的那一份。
优点:
-
控制逻辑与数据读取逻辑简单;
-
用户数据独立,满足更多的业务场景,比如:回执消息、云端删除等等;
-
一个数据点丢失,不影响其他用户的数据点。
缺点:
-
存储空间的增加;
-
写扩散需要专门的扩散队列;
-
先写扩散后读,实时性差。
企业微信的写扩散架构
每条消息存多份,每个群聊成员在自己的存储都有一份。
优点:
-
① 只需要通过一个序列号就可以增量同步所有消息,收消息协议简单;
-
② 读取速度快,前端体验好;
-
③ 满足更多ToB的业务场景:回执消息、云端删除。
同一条消息,在每个人的视角会有不同的表现。例如:回执消息,发送方能看到已读未读列表,接受方只能看到是否已读的状态。云端删除某条群消息,在自己的消息列表消失,其他人还是可见。
缺点:存储容量的增加。
企业微信采用了扩散写的方式,消息收发简单稳定。存储容量的增加,可以通过冷热分离的方案解决,冷数据存到廉价的SATA盘,扩散读体验稍差,协议设计也相对复杂些。
下图是扩散写的协议设计:
如上图所示:
-
1)每个用户只有一条独立的消息流。同一条消息多副本存在于每个用户的消息流中;
-
2)每条消息有一个seq,在同个用户的消息流中,seq是单调递增的;
-
3)客户端保存消息列表中最大seq,说明客户端已经拥有比该seq小的所有消息。若客户端被push有新消息到达,则用该seq向后台请求增量数据,后台把比此seq大的消息数据返回。
7、系统架构异步解耦
总的方案:
-
企业微信的消息系统,会依赖很多外部模块,甚至外部系统。
-
与外部系统的交互,全设计成异步化。
为了避免外部系统或者外部模块出现故障,拖累消息系统,导致耗时增加,则需要系统解耦。
解耦之后,进行通过异步mq,去异步重试去保证成功,主流程不受影响。
例如ImUnion异步化:
与微信消息互通时,通过外部系统ImUnion进行权限判断,调用耗时较长。
如何异步化:先让客户端成功,如果ImUnion异步失败,则回调客户端使得出红点。
再如消息审计功能异步化:
金融版的消息审计功能,需要把消息同步到审计模块,增加rpc调用。
异步化策略:消息审计功能是非主流程,则异步重试机制,去保证成功,主流程不受影响。
再如crm模块异步:
客户服务的单聊群聊消息,需要把消息同步到crm模块,增加rpc调用。
异步化策略:crm模块异步功能是非主流程,则异步重试机制,去保证成功,主流程不受影响。
8、业务隔离架构设计
企业微信的消息类型有多种:
-
1)单聊群聊:基础聊天,优先级高;
-
2)api 消息:企业通过api接口下发的消息,有频率限制,优先级中;
-
3)应用消息:系统应用下发的消息,例如公告,有频率限制,优先级中;
-
4)控制消息:不可见的消息。例如群信息变更,会下发控制消息通知群成员,优先级低。
群聊按群人数,又分成3类:
-
1)普通群:小于100人的群,优先级高;
-
2)大 群:小于2000人的群,优先级中;
-
3)万人群:优先级低。
业务繁多:如果不加以隔离,那么其中一个业务的波动有可能引起整个消息系统的瘫痪。
重中之重:需要保证核心链路的稳定,就是企业内部的单聊和100人以下群聊,因为这个业务是最基础的,也是最敏感的,稍有问题,投诉量巨大。
其余的业务:互相隔离,减少牵连。按照优先级和重要程度进行隔离,对应的并发度也做了调整,尽量保证核心链路的稳定性。
解耦和隔离的效果图:
9、过载保护架构设计
10亿级用户,如何做 熔断降级架构?微信和hystrix的架构对比
服务过载问题
上一小结中过载保护策略所带来的问题就是:系统过载返回失败,前端发消息显示失败,显示红点,会严重影响产品体验。
发消息是im系统的最基础的功能,可用性要求达到几乎100%,所以这个策略肯定需要优化。
解决方案
解决方案思路就是:尽管失败,也返回前端成功,后台保证最终成功。
为了保证消息系统的可用性,规避高峰期系统出现过载失败导致前端出红点,做了很多优化。
具体策略如下:
-
1)逻辑层hold住失败请求,返回前端成功,不出红点,后端异步重试,直至成功;
-
2)为了防止在系统出现大面积故障的时候,重试请求压满队列,只hold住半小时的失败请求,半小时后新来的请求则直接返回前端失败;
-
3)为了避免重试加剧系统过载,指数时间延迟重试;
-
4)复杂的消息鉴权(好友关系,企业关系,集团关系,圈子关系),耗时严重,后台波动容易造成失败。如果并非明确鉴权不通过,则幂等重试;
-
5)为了防止作恶请求,限制单个用户和单个企业的请求并发数。例如,单个用户的消耗worker数超过20%,则直接丢弃该用户的请求,不重试。
优化后,后台的波动,前端基本没有感知。
以下是优化前后的流程对比:
10、万人大群的架构优化
10.1 技术背景
企业微信的群人数上限是10000,只要群内每个人都发一条消息,那么扩散量就是10000 * 10000 = 1亿次调用,非常巨大。
10000人投递完成需要的耗时长,影响了消息的及时性。
10.2 问题分析
既然超大群扩散写量大、耗时长,那么自然会想到:超大群是否可以单独拎出来做成扩散读呢。
下面分析一下超大群设计成单副本面临的难点:
-
① 一个超大群,一条消息流,群成员都同步这条流的消息;
-
② 假如用户拥有多个超大群,则需要同步多条流,客户端需维护每条流的seq;
-
③ 客户端卸载重装,并不知道拥有哪些消息流,后台需存储并告知;
-
④ 某个超大群来了新消息,需通知所有群成员,假如push没触达,客户端没办法感知有新消息,不可能去轮训所有的消息流。
综上所述:单副本的方案代价太大。
以下将介绍我们针对万人群聊扩散写的方案,做的一些优化实践。
10.3 优化1:并发限制
万人群的扩散量大,为了是消息尽可能及时到达,使用了多协程去分发消息。但是并不是无限制地加大并发度。
为了避免某个万人群的高频发消息,造成对整个消息系统的压力,消息分发以群id为维度,限制了单个群的分发并发度。
消息分发给一个人的耗时是8ms,那么万人的总体耗时是80s,并发上限是5,那么消息分发完成需要16s。16s的耗时,在产品角度来看还、是可以接受的,大群对及时性不敏感。同时,并发度控制在合理范围内。
除了限制单个群id的并发度,还限制了万人群的总体并发度。单台机,小群的worker数为250个,万人群的worker数为30。
10.4 优化2:合并插入
工作场景的聊天,多数是在小群完成,大群用于管理员发通知或者老板发红包。
大群消息有一个常见的规律:平时消息少,会突然活跃。例如:老板在群里发个大红包,群成员起哄,此时就会产生大量的消息。
消息量上涨、并发度被限制、任务处理不过来,那么队列自然就会积压。积压的任务中可能存在多条消息需要分发给同一个群的群成员。
此时:可以将这些消息,合并成一个请求,写入到消息存储,消息系统的吞吐量就可以成倍增加。
在日常的监控中,可以捕获到这种场景,高峰可以同时插入20条消息,对整个系统很友善。
10.5 优化3:业务降级
比如:群人员变更、群名称变动、群设置变更,都会在群内扩散一条不可见的控制消息。群成员收到此控制消息,则向后台请求同步新数据。
举个例子:一个万人群,由于消息过于频繁,对群成员造成骚扰,部分群成员选择退群来拒绝消息,假设有1000人选择退群。那么扩散的控制消息量就是1000w,用户收到控制消息就向后台请求数据,则额外带来1000w次的数据请求,造成系统的巨大压力。
控制消息在小群是很有必要的,能让群成员实时感知群信息的变更。
但是在大群:群信息的变更其实不那么实时,用户也感觉不到。所以结合业务场景,实施降级服务,控制消息在大群可以直接丢弃、不分发,减少对系统的调用。
11、回执消息架构设计
11.1 技术背景
回执消息是办公场景经常用到的一个功能,能看到消息接受方的阅读状态。
一条回执消息的阅读状态会被频繁修改,群消息被修改的次数和群成员人数成正比。每天上亿条消息,读写频繁,请求量巨大,怎么保证每条消息在接受双方的状态是一致的是一个难点。
11.2 实现方案
消息的阅读状态的存储方式两个方案。
方案一:
思路:利用消息存储,插入一条新消息指向旧消息,此新消息有最新的阅读状态。客户端收到新消息,则用新消息的内容替换旧消息的内容展示,以达到展示阅读状态的效果。
优点:复用消息通道,增量同步消息就可以获取到回执状态,复用通知机制和收发协议,前后端改造小。
缺点:
-
① 存储冗余,状态变更多次,则需插入多条消息;
-
② 收发双方都需要修改阅读状态(接收方需标志消息为已读状态),存在收发双方数据一致性问题。
方案二:
思路:独立存储每条消息的阅读状态,消息发送者通过消息id去拉取数据。
优点:状态一致。
缺点:
-
① 构建可靠的通知机制,通知客户端某条消息属性发生变更;
-
② 同步协议复杂,客户端需要准确知道哪条消息的状态已变更;
-
③ 消息过期删除,阅读状态数据也要自动过期删除。
企业微信采用了方案一去实现,简单可靠、改动较小:存储冗余的问题可以通过LevelDB落盘的时候merge数据,只保留最终状态那条消息即可;一致性问题下面会介绍如何解决。
上图是协议流程(referid:被指向的消息id,senderid:消息发送方的msgid):
-
1)每条消息都有一个唯一的msgid,只在单个用户内唯一,kv存储自动生成的;
-
2)接收方b已读消息,客户端带上msgid=b1请求到后台;
-
3)在接受方b新增一条消息,msgid=b2,referid=b1,指向msgid=b1的消息。并把msgid=b2的消息内容设置为消息已读。msgid=b1的消息体,存有发送方的msgid,即senderid=a1;
-
4)发送方a,读出msgid=a1的消息体,把b加入到已读列表,把新的已读列表保存到消息体中,生成新消息msgid=a2,referid=a1,追加写入到a的消息流;
-
5)接收方c已读同一条消息,在c的消息流走同样的逻辑;
-
6)发送方a,读出msgid=a1的消息体,把c加入到已读列表,把新的已读列表保存到消息体中,生成新消息msgid=a3,referid=a1,追加写入到a的消息流。a3>a2,以msgid大的a3为最终状态。
11.3 优化1:异步化
接受方已读消息,让客户端同步感知成功,但是发送方的状态没必要同步修改。因为发送方的状态修改情况,接受方没有感知不到。
那么,可以采用异步化的策略,降低同步调用耗时。
具体做法是:
-
1)接受方的数据同步写入,让客户端马上感知消息已读成功;
-
2)发送方的数据异步写入,减少同步请求;
-
3)异步写入通过重试来保证成功,达到状态最终一致的目的。
11.4 优化2:合并处理
客户端收到大量消息,并不是一条一条消息已读确认,而是多条消息一起已读确认。为了提高回执消息的处理效率,可以对多条消息合并处理。
如上图所示:
-
1)X>>A:表示X发了一条消息给A;
-
2)A合并确认3条消息,B合并确认3条消息。那么只需要处理2次,就能标志6条消息已读;
-
3)经过mq分发,相同的发送方也可以合并处理。在发送方,X合并处理2条消息,Y合并处理2条消息,Z合并处理2条消息,则合并处理3次就能标志6条消息。
经过合并处理,处理效率大大提高。
11.5 读写覆盖解决
发送方的消息处理方式是先把数据读起来,修改后重新覆盖写入存储。接收方有多个,那么就会并发写发送方数据,避免不了出现覆盖写的问题。
流程如下:
-
1)发送方某条消息的已读状态是X;
-
2)接收方a确认已读,已读状态修改为X+a;
-
3)接收方b确认已读,已读状态修改为X+b;
-
4)接收方a的状态先写入,接受方b的状态后写入。这最终状态为X+b;
-
5)其实正确的状态是X+a+b。
处理这类问题,无非就一下几种办法。
方案一:因为并发操作是分布式,那么可以采用分布式锁的方式保证一致。操作存储之前,先申请分布式锁。这种方案太重,不适合这种高频多账号的场景。
方案二:带版本号读写。一个账号的消息流只有一个版本锁,高频写入的场景,很容易产生版本冲突,导致写入效率低下。
方案三:mq串行化处理。能避免覆盖写问题,关键是在合并场景起到很好的作用。同一个账号的请求串行化,就算出现队列积压,合并的策略也能提高处理效率。
企业微信采用了方案三,相同id的用户请求串行化处理,简单易行,逻辑改动较少。
12、撤回消息的架构设计
12.1 技术难点
“撤回消息”相当于更新原消息的状态,是不是也可以通过referid的方式去指向呢?
回执消息分析过:通过referid指向,必须要知道原消息的msgid。
区别于回执消息:撤回消息需要修改所有接收方的消息状态,而不仅仅是发送方和单个接收方的。消息扩散写到每个接收方的消息流,各自的消息流对应的msgid是不相同的,如果沿用referid的方式,那就需要记录所有接收方的msgid。
12.2 解决方案
分析:撤回消息比回执消息简单的是,撤回消息只需要更新消息的状态,而不需要知道原消息的内容。接收方的消息的appinfo都是相同的,可以通过appinfo去做指向。
协议流程:
-
1)用户a、b、c,都存在同一条消息,appinfo=s,sendtime=t;
-
2)a撤回该消息,则在a的消息流插入一条撤回的控制消息,消息体包含{appinfo=s,sendtime=t};
-
3)客户端sync到撤回的控制消息,获取到消息体的appinfo与sendtime,把本地appinfo=s且sendtime=t的原消息显示为撤回状态,并删除原消息数据。之所以引入sendtime字段,是为了防止appinfo碰撞,加的双重校验;
-
4)接收方撤回流程和发送方一致,也是通过插入撤回的控制消息。
该方案的优点明显,可靠性高,协议简单。
撤回消息的逻辑示意图:
手写阻塞队列
1、什么是阻塞队列?
阻塞队列是一种队列,阻塞队列是一种特殊的队列。
阻塞队列是一种可以在多线程环境下使用,并且支持阻塞等待的队列。
线程 1 往阻塞队列中添加元素,当阻塞队列是满的,线程 1就会阻塞,直到队列不满
线程 2 从阻塞队列中移除元素,当阻塞队列是空的,线程 2 会阻塞,直到队列不空;
2、阻塞队列的作用
阻塞队列,也就是 BlockingQueue,它是一个接口,如代码所示:
public interface BlockingQueue<E> extends Queue<E>{...}
BlockingQueue 继承了 Queue 接口,是队列的一种。
Queue 和 BlockingQueue 都是在 Java 5 中加入的。
BlockingQueue 是线程安全的,在很多场景下都可以利用线程安全的队列来优雅地解决业务自身的线程安全问题。
比如说,使用生产者/消费者模式的时候,生产者只需要往队列里添加元素,而消费者只需要从队列里取出它们就可以了,如图所示:
阻塞队列区别于其他类型的队列的最主要的特点就是“阻塞”这两个字,阻塞功能使得生产者和消费者两端的能力得以平衡,当有任何一端速度过快时,阻塞队列便会把过快的速度给降下来。
3、阻塞队列的核心方法
方法类型 | 抛出异常 | 特殊值 | 阻塞 | 超时 |
---|---|---|---|---|
插入 | add(e) | offer(e) | put(e) | offer(e,time,unit) |
移除 | remove() | poll() | take() | poll(time,unit) |
检查 | element | peek | 不可用 | 不可用 |
1、抛异常的方法 就是在插入满了之后,会报一个异常,remove一样,element是检查队头的元素或者是否为空。
2、特殊值的方法是在插入满之后返回值变成了false而不是一个异常,取出失败的时候返回null。
3、阻塞方法是在插入满之后把这个方法阻塞,一直等待队列空出来一个之后再进行加入,会出现一直等待,也可能出现饥饿现象。
4、超时方法的话,当阻塞队列满时,队列会阻塞生产者线程一定时间,超过限时后生产者线程会退出。
实现阻塞最重要的两个方法是 take 方法和 put 方法。
3.1 take 方法
take 方法的功能是获取并移除队列的头结点,通常在队列里有数据的时候是可以正常移除的。
可是一旦执行 take 方法的时候,队列里无数据,则阻塞,直到队列里有数据。
一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。
过程如图所示:
3.2 put 方法
put 方法插入元素时,如果队列没有满,那就和普通的插入一样是正常的插入,但是如果队列已满,那么就无法继续插入,则阻塞,直到队列里有了空闲空间。
如果后续队列有了空闲空间,比如消费者消费了一个元素,那么此时队列就会解除阻塞状态,并把需要添加的数据添加到队列中。
put 过程如图所示:
以上过程中的阻塞和解除阻塞,都是 BlockingQueue 完成的,不需要我们自己处理。
4、手写模拟实现一个阻塞队列
手写模拟实现一个阻塞队列,可以基于数组实现的阻塞队列,如何手写呢?
我们先从功能设计开始:
-
首先它是一个队列,队列需要具备入队、出队的能力, 所以,设计两个方法 put、take
-
put操作的时候,需要在队列已满时,对入队的请求进行阻塞,当队列有剩余空间时,释放入队请求;
-
take操作的时候,在队列为空时,需要对出队的请求进行阻塞,当队列中有元素时,释放出队请求;
-
由于ArrayBlockingQueue是一个在多线程情况下使用的数据结构,需要保证它的操作的线程安全性,所以,这里需要用到锁
4.1.用数组实现队列
如何用数组实现数据的入队出队操作呢?
如何写入呢?
这个简单,可以通过一个index字段存储当前数组下一个写入的位置。
如何处理出队呢?
一种简单的方法 :简单的返回数组第一个元素,并且把后面所有的元素向前移动一位。
如果这么操作,出队时会移动大量的元素,它的时间复杂度是O(n)。
那有没有更高效的方案呢?
还有另一个循环数组的方案,我们通过两个int字段,分别记录下一个要入队和下一个要出队的元素的位置,当入队到数组末尾时,从0开始,同样当出队到末尾时,也从0开始。
另外当队列为空和队列已满的时候,takeIndex和putIndex都指向相同的位置,所以为了进行区分,我们可以用一个count字段存储队列元素数量,这样当count=0的时候说明队列为0,count=数组容量的时候说明队列已满
4.2.使用 synchronized 实现
由于 synchronized 是同一把锁,所以使用 notify() 可能会唤醒非目标线程,notifyAll() 唤醒全部线程则会带来大量的 CPU 上下文交换和锁竞争
package com.crazymakercircle.queue;
public class ArrayBlockingQueue{
private Object[] array; //数组
private int takeIndex; //头
private int putIndex; //尾
private volatile int count; //元素个数
public ArrayBlockingQueue(int capacity){
this.array = new Object[capacity];
}
//写入元素
public synchronized void put(Object o) throws InterruptedException{
//当队列满时,阻塞
while(count == array.length){
this.wait();
}
array[putIndex++] = o;
if(putIndex ==array.length){
putIndex = 0;
}
count++;
//唤醒线程
this.notifyAll();
}
//取出元素
public synchronized Object take() throws InterruptedException{
//当队列为空,阻塞
while(count == 0){
this.wait();
}
Object o = array[takeIndex++];
if(takeIndex == array.length){
takeIndex = 0;
}
count--;
//唤醒线程
this.notifyAll();
return o;
}
}
4.3.使用 ReentrantLock
可以使用 Condition 指定要唤醒的线程,所以效率高
package com.crazymakercircle.queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ArrayBlockingQueueReentrantLock{
private Object[] array; //数组
private int takeIndex; //头
private int putIndex; //尾
private volatile int count; //元素个数
private ReentrantLock lock = new ReentrantLock(); //锁
private Condition notEmpty = lock.newCondition(); //非空
private Condition notFull = lock.newCondition(); //非满
public ArrayBlockingQueueReentrantLock(int capacity){
this.array = new Object[capacity];
}
//写入元素
public void put(Object o) throws InterruptedException{
try{
lock.lock();
//当队列满时,阻塞
while(count == array.length){
notFull.wait();
}
array[putIndex++] = o;
if(putIndex == array.length){
putIndex = 0;
}
count++;
//唤醒线程
notEmpty.notifyAll();
}finally{
lock.unlock();
}
}
//取出元素
public Object take() throws InterruptedException{
lock.lock();
try{
//当队列为空,阻塞
while(count == 0){
notEmpty.wait();
}
Object o = array[takeIndex++];
if(takeIndex == array.length){
takeIndex = 0;
}
count--;
//唤醒线程
notFull.notifyAll();
return o;
}finally{
lock.unlock();
}
}
}
接下来,拆解JUC源码中,ArrayBlockingQueue的实现步骤
5、拆解ArrayBlockingQueue实现步骤
我们先拆解一下问题,把拆解ArrayBlockingQueue实现步骤分成两个步骤
-
用数组实现队列
-
给队列加上阻塞能力和保证线程安全
5.1 用数组实现队列
使用 takeIndex、putIndex 避免数组复制
下面代码展示了用数组实现队列的具体实现。
class ArrayBlockingQueue<E> {
final Object[] items;
int takeIndex;
int putIndex;
int count;
public ArrayBlockingQueue(int capacity) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
}
private void enqueue(E e) {
Object[] items = this.items;
items[putIndex] = e;
if (++putIndex == items.length) putIndex = 0;
count++;
}
private E dequeue() {
Object[] items = this.items;
E e = (E) items[takeIndex];
items[takeIndex] = null;
if (++takeIndex == items.length) takeIndex = 0;
count--;
return e;
}
}
5.2 实现条件阻塞和线程安全
「在队列已满时,对入队的请求进行阻塞,当队列有剩余空间时,释放入队请求」这个需求本质上是一个条件等待的特例,写入的条件是队列不满,不满足条件的时候需要等待,直到满足条件为止。
在Java中,实现条件等待有synchronized+Object.wait和Lock+Condition.await两种方式,这里不用synchronized方案,是因为
-
synchronized不支持interrupt
-
synchronized无法支持多个条件
通过Lock和Condition的方案,还能够保证线程安全,因为上面的环形数组实现中,线程间共享的变量有items数组、takeIndex、putIndex、count,线程安全涉及到原子性可见性重排序几个方面,通过Lock类加锁可以对共享变量的读写操作进行保护。
定义阻塞的Lock对象和Condition,条件分为不满和不空两个条件。
class ArrayBlockingQueue<E> {
final Object[] items;
int takeIndex;
int putIndex;
int count;
ReentrantLock lock;
private final Condition notEmpty;
private final Condition notFull;
public ArrayBlockingQueue(int capacity) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
// 创建lock对象
lock = new ReentrantLock();
// 创建非空的Condition
notEmpty = lock.newCondition();
// 创建不满的Condition
notFull = lock.newCondition();
}
}
以入队操作添加实现为例,能够入队的条件是队列不满,也就是count < items.length,不能入队的条件反过来就是count == items.length。
当满足条件后,我们就可以入队了,入队之后,还需要唤醒等待出队的线程。
5.3 put方法的流程为
-
先加锁
-
在锁中while循环判断条件是否满足,不满足调用notFull.await(),await()方法会释放锁,被其他线程signal唤醒后会重新抢锁,再次获得锁后会继续走到while循环判断条件的地方。
-
如果条件已经满足,则执行入队操作
-
入队完之后调用notEmpty.signal()唤醒一个等待notFull条件的线程
-
finally中释放锁
public void put(E e) throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == items.length)
notFull.await();
enqueue(e);
notEmpty.signal();
} finally {
lock.unlock();
}
}
方法中还有一些小细节
-
put方法中,为什么要先用一个声明一个lock局部变量呢?
ReentrantLock lock = this.lock;
这是因为如果不使用局部变量,后面所有使用实例变量的调用,在字节码指令层面需要变成先调用aload 0获取到this,再调用getField指令获取字段值,再进行其他操作。而先把lock存到局部变量中,后面所有的获取lock就可以变成一个aload xxx指令,从而节省了指令数量,也就会加快方法的执行速度。
-
为什么while循环需要放在锁内呢?
如果不放在锁内,则可能会出现多个线程同时看到满足条件,进而去加锁入队。虽然入队还是在临界区,但是会出现队列已满,仍然在执行入队操作的情况。这个问题和单例的double check locking中少些一个check的问题类似。
5.4 take方法的流程为
take方法是和put相对应的出队方法,和put流程基本一致
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
E element = dequeue();
notFull.signal()
return element;
} finally {
lock.unlock();
}
}