1.同步和异步
同步/异步指的是【消息通信机制】
同步(Synchronous)指的是,主动轮询结果
异步 (Asynchronous)指的是,被动接受通知
2.阻塞和非阻塞
进程进入waiting状态,就是阻塞
阻塞/非阻塞,关注的是调用方的状态,往往是与系统调用(system call)紧密联系在一起的
因为一个进程/线程进入waiting状态,要么是主动wait() or sleep(),要么就是调用了一些涉及IO操作的system call,由于IO不能立即完成,因此内核会先将其挂起,等IO complete之后再将其状态置为ready
3.最原始的单线程同步&阻塞式IO
最原始的同步&阻塞式IO是单线程模式,从始至终都是一个线程负责建立连接+读取交互数据+执行业务逻辑。代码模版如下
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
int n = read(connfd, buf); // 阻塞在read(),直到读到数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
在用户态执行系统调用accept会发生阻塞,连接建立后accept返回一个【连接文件描述符connfd】
然后,在用户态执行系统调用read会发生阻塞——当网卡将数据拷贝到内核缓冲区时,connfd由【读未就绪】状态变成【读就绪】状态,这时read解除阻塞状态,将内核缓冲区的数据拷贝到用户态的buf
在这个模式中,服务端和客户端之间建立连接是严格串行的,服务端的一个网络通信端口只能与一个客户端进行连接,并且需要等到当前连接关闭后,才可以与新的客户端再次建立连接
4.多线程的同步&阻塞式IO
为了解决上文的痛点,改变一下模版:
对于每个已建立的连接connfd,主线程都另开一个线程去handle,而主线程只负责连接的建立
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
thread_create(handler(connfd)); // 新开线程处理连接
}
func handler(connfd) {
int n = read(connfd, buf); // 阻塞在read,直到读到数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接
}
这样的改动可以解决一部分问题,但是每个handler线程内进行read系统调用依然是阻塞的;同时,一个连接对应一个线程,当连接量大的时候,服务端线程数量过多,对资源的消耗是比较大的;同时线程的创建、切换和销毁也产生了不少开销
5.同步&非阻塞式IO--调整read()的返回
操作系统为了适应新的需求,将原本是阻塞的系统调用read调整成非阻塞。思路很简单,当connfd处于【读未就绪】状态时,直接返回-1;当connfd处于【读就绪】状态时,将数据从内核缓冲区拷贝至用户态内存空间并返回
6.应用程序中实现IO多路复用
先解释下IO多路复用:
多路
指多个文件描述符fd;复用
指复用同一个进程/线程同时监听前面提到的多个文件描述符fd
有了非阻塞的read这个基础,我们可以在应用程序中,将多个connfd组织成一个connfd数组,这样一个线程就可以通过轮询connfd数组的方式来handle多个connfd了
// 应用程序connfdlist示意代码
while(1) {
for fd in connfdlist {
if (read(fd) != -1) {
doSomeThing();
}
}
}
7.操作系统内核实现IO多路复用
在应用程序中实现IO多路复用的方式中,在一次轮询内,每个connfd都要进行一次read系统调用,遇到 read 返回 -1 时仍然是一次浪费资源的系统调用
于是,我们把connfd数组整体传给操作系统内核,在内核层完成遍历,返回遍历结果。这就得到了第一代IO多路复用模式--select
7.1 select
select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理
while(1) {
nready = select(fdlist); // 阻塞在select,直到有fd可读
// 用户层依然要遍历,只不过少了很多无效的系统调用
for fd in fdlist {
if (fd != -1) {
// 只会对已就绪的文件描述符执行read系统调用,未就绪的文件描述符被跳过
read(fd, buf);
// 总共只有 nready 个已就绪描述符,不用过多遍历
if(--nready == 0) break;
}
}
}
- select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
- select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
- select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
- select这个系统调用是阻塞的
- select可监听的fd个数在Linux上是1024,因为select模式下的fd是通过数组存储的(poll使用链表存储监听的fd集合,解决了select监听fd集合大小受限的问题)
7.2 epoll
epoll针对select的三个可优化点进行了改进:
- 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
- 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
具体,操作系统提供了这三个函数。
第一步,创建一个 epoll 句柄
int epoll_create(int size);
第二步,向内核添加、修改或删除要监控的文件描述符,对应改进1
int epoll_ctl(
int epfd, int op, int fd, struct epoll_event *event);
第三步,类似发起了 select() 调用,会阻塞进程,会返回就绪态的fd
int epoll_wait(
int epfd, struct epoll_event *events, int max events, int timeout);
注意:
应用程序调用select、poll还是epoll,都是直到有就绪的fd才会返回,那么对应用程序本身来说都是阻塞式的调用
区别仅在于它们内部如何去判断是否有fd就绪:这里面select和poll是非阻塞的,使用了非阻塞read不断轮询地去判断fd的就绪状态;而epoll内部是阻塞式的,它使用了事件唤醒的异步机制
但再次强调,这些系统调用内部的逻辑对应用程序是透明的,对应用程序本身来说都是阻塞式的调用
参考:https://mp.weixin.qq.com/s/YdIdoZ_yusVWza1PU7lWaw
7.2.1 epoll-wq与epoll惊群
ep->wq是一个等待队列,用来保存【对该epoll实例调用epoll_wait()】的所有进程
一个进程调用 epoll_wait()后,如果监视的文件还没有任何事件发生,需要让当前进程挂起等待(放到 ep->wq 里);当 epoll 实例监视的文件上有事件发生后,需要唤醒 ep->wq 上的进程去继续执行用户态的业务逻辑
之所以要用一个等待队列来维护关注这个 epoll 的进程,是因为有时候调用 epoll_wait()的不只一个进程,当多个进程都在关注同一个 epoll 实例时,休眠的进程们通过这个等待队列就可以逐个被唤醒了
这就引出了惊群现象
- 情况1: 多个进程等待在 ep->wq 上,或者说多个进程共用一个epoll实例
- 情况2: 多个进程各自使用独立的epoll实例,但这些epoll实例对同一个fd进行了监听
在这两种情况下,epoll监听的fd有事件触发后所有进程都被唤醒,但只有其中 1 个进程能够成功继续执行,被形象地称为惊群现象。就像往鸡群里投下一粒米,所有的鸡都飞起来啄食,但只会有一只鸡吃到
为了解决 epoll 惊群,内核后续的高版本又提供了 EPOLLEXCLUSIVE option 和 SO_REUSEPORT option,我个人理解两种解决方案思路上的不同点在于:EPOLLEXCLUSIVE 是在唤起进程阶段起作用,只唤起排在队列最前面的 1 个进程;而 SO_REUSEPORT 是在分配连接时起作用,相当于每个进程自己都有一个独立的 epoll 实例,内核来决策把连接分配给哪个 epoll
网友评论