在看kafka的生产者基于NIO构建网络通信层NetworkClient的时候,发觉自己对网络通信的相关知识(同步,异步,阻塞,非阻塞, Reactor,Proactor,Linux的IO模型,IO的多路复用等知识)有点模糊,熟悉喃也好像不怎么熟悉,了解喃也好像不怎么了解。那么就在这里重新梳理一遍。
同步和异步针对应用程序,关注的是程序之间的协作关系;阻塞和非阻塞关注的是进程/线程之间的执行状态(正在执行或者是挂起)。
同步 异步
同步:执行一个操作之后,等待结果后,然后才继续执行后续的操作。
异步:执行一个操作之后,可以去执行其他的操作,然后等待通知后,再回来执行刚刚那个操作后续的操作。
阻塞 非阻塞
阻塞:进程给CPU一个任务后,一直等待CPU处理完成,然后才执行后面的操作。
非阻塞:进程给CPU一个任务后,继续处理后面的操作,间隔一段时间在来询问之前的任务是否完成,完成了,就继续执行后续的操作。这个过程叫轮询。
阻塞和非阻塞是指进程/线程访问的数据如果尚未就绪,进程/线程是否需要等待。
同步和异步是指访问数据的机制:同步一般指主动请求并等待I/O操作完毕的方式,当数据就绪后进程/线程在读写的时候必须阻塞(等待数据从内核缓冲区复制到用户缓冲区)。异步则指主动请求I/O数据后,继续处理其他任务。随后等待I/O操作完毕的通知(callback),这样可以使进程/线程在读写时不阻塞。
网络IO的本质是socket的读取,socket在Linux系统被抽象为流,IO可以理解为对流的操作。对于一次IO访问,数据会先被拷贝到操作系统内核的缓冲区中,然后再从操作系统内核的缓冲区拷贝到应用程序的地址空间。
同步模型
阻塞IO
最常用的IO模型就是阻塞IO模型,在缺省条件下,所有的socket操作都是阻塞的,以socket读为例(底层是调用recvfrom方法):
在用户空间调用recvfrom,直到数据包全部达到并且从内核缓冲区复制到应用进程的缓冲区或者中间发生异常返回的这段期间进程/线程一直等待。进程/线程从调用recvfrom开始到recvfrom返回整段时间内都被阻塞----阻塞IO模型。
非阻塞IO
非阻塞的recvfrom调用,从应用到内核时,如果缓冲区没有数据,进程不会被阻塞,内核会马上返回一个EWOULDBLOCK错误。这样进程可以做点其他的事情,然后进程再发起recvfrom调用,如果缓冲区还没有数据,又会返回EWOULDBLOCK错误。就这样循环往复的进行recvform系统调用,这种就叫做轮询。轮询检查到内核的缓冲区有数据准备好了,再拷贝到应用的缓冲区,进程进行数据处理。数据从内核缓冲区到应用的缓冲区这个时间段,进程属于阻塞状态。
多路复用IO
Linux提供了select,poll,epoll。应用系统调用阻塞于select调用,等待socket变为可读,就返回这个可读的条件。进程再调用recvfrom把数据从内核缓冲区复制到应用缓冲区(这个过程是阻塞的)
前面的非阻塞IO需要用户应用不停的去轮询,并且一个进程/线程只能关注一个socket。多路复用IO不需要用户应用去不停的轮询,而是让Linux下的select,poll,epoll去帮忙轮询多个任务的完成情况。select调用是内核级别,select轮询相对非阻塞IO的轮询的区别在于---select轮询可以等待多个socket,能实现同时对多个IO端口进行监听。当其中任何一个socket的数据准好了,就能返回进行可读,然后进程再进行recvform系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。调用select后,会阻塞进程,于阻塞IO不同(等待所有数据都到达),select不是等到socket数据全部到达再处理, 而是有了一部分数据(网络上的数据是分组到达的,如何知道有一部分数据到达了呢?监视的事情交给了内核,内核负责数据到达的处理)就会通知用户应用读准备好了,然后进程调用recvform来处理。
1,对多个socket进行监听,只要任何一个socket数据准备好就返回可读。
2,不等一个socket数据全部到达再处理,而是一部分socket的数据到达了就通知用户进程。这就是我们经常在代码里面写循环来处理数据:
while ((networkReceive = channel.read()) != null){
// 处理读取到的一部分的socket数据
}
信号驱动IO
首先开启套接字的信号驱动式IO功能,并且通过sigaction系统调用安装一个信号处理函数,该函数调用将立即返回,当前进程没有被阻塞,继续工作;当数据报准备好的时候,内核则为该进程产生SIGIO的信号,随既可以在信号处理函数中调用recvfrom读取数据报,并且通知主循环;数据已经准备好等待处理,通知主循环读取数据报;(一个待读取的通知和待处理的通知);
异步IO
告知内核启动读取事件,并让内核在整个操作完成后(包括将数据从内核缓冲区复制到应用程序缓冲区)通知用户进程。
文件描述符(fd)
文件描述符(file descriptor,简称fd),是一个非负整数。当进程打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在Linux系统中,内核将所有的外部设备都当做一个文件来进行操作,而对于一个文件的读写操作会调用内核提供的系统命令,返回一个fd。对于一个socket的读写也会有相应的描述符---socketfd(socket描述符),指向内核中的一个结构体(文件路径,数据区域等属性)。
Linux的IO多路复用模型
基本思想:先构造一张有关文件描述符的表,然后调用一个函数,这个函数要到这些文件描述符中一个已经准备好进行IO操作后才返回。在返回时,此函数告诉进程哪一个文件描述符已经准备好可以进行IO操作。
IO多路复用通过把多个IO阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下,可以同时处理多个 client 请求,与传统的多线程相比,IO多路复用的最大优势是系统开销小,系统不需要创建新的额外的进程或线程,也不需要维护这些进程和线程的运行,节省了系统资源。
目前支持I/O多路复用的系统有select,pselect,poll,epoll,I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,pselect,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。用户进程之间从用户缓冲区中读取数据。真正的IO阻塞是数据从内核缓冲区复制到用户缓冲区中,这正是上面recvfrom方法做的事情。所以从消息机制来看都是同步IO。
select
select函数的参数会告诉内核:
1,关心的文件描述符有哪些。
2,对于每一个文件描述符关心的条件是什么:是否可以读一个文件描述符,是否可以写一个文件描述符,文件描述符的异常情况。
3,进程希望等待多长的时间(在没有文件描述符准备的时候,好久返回):可以永远等待,等待一个固定的时间,完全不等待。
在select函数返回时,内核会告诉我们:
1,已经准备好的文件描述符的数量。
2,哪一个文件描述符已准备好读,写或者异常条件。
select函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。这三个指针是指向描述符集的。这三个描述符集代表了我们关心的可读,可写或处于异常条件的各个描述符。每个描述符集存放在一个fd_set数据类型中,fd_set用了一个32位矢量来表示fd,Linux中FD_SETSIZE设置默认是1024。调用后select函数会阻塞,直到有描述符就绪(有数据可读、可写),或者超时(timeout指定等待时间)函数返回。当select函数返回后,可以通过遍历fd_set,来找到就绪的描述符。
select的缺点:
1,在于单个进程能够监视的文件描述符的数量存在最大限制。由FD_SETSIZE设置,在Linux上默认为1024。
2,当socket比较多的时候,每次的select都要遍历FD_SETSIZE个socket。不是遍历的socket是否是活跃的,都要遍历一遍。浪费很多CPU时间。
3,需要维护一个用来存放大量fd的数据结构(操作socket,其实就是对文件描述符的操作),这样在从内核空间复制到用户空间开销很大。
poll
poll函数类似select,与select不同的是,poll不是为每个条件构造一个描述符集(writefds、readfds、和exceptfds),而是构造一个fd结构数组,数组中每一个元素指定一个描述符编号和关心的事件。查询每个fd对应的设备状态。如果设备就绪则在设置数组中描述符编号对应的描述符发生了什么事件(revent),如果遍历完所有 fd 后没有发现就绪设备,则挂起当前进程。直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
poll没有FD_SETSIZE限制。因为采用数组方式存储要检查的fd。
poll的缺点:
1,fd结构数组(数据量大)被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
2,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
select和poll最后都需要遍历文件描述符来获取已经就绪的socket。因为我们只是知道有事件发生,但是并不知道是那几个流,所以只能无差别的轮询一遍所有的流。其实在同时连接大量客户端在一时刻可能只有很少的处于就绪状态。但是也要全部遍历,这样随着监视的描述符数量的增长,其效率也会线性下降。
epoll
在内核里,一切都是文件。epoll会向内核注册一个文件系统,用于存储被监控的socket描述符。epoll在被内核初始化的时(操作系统启动时),会开辟属于epoll自己的内核高速缓存区。
epoll的相关操作
int epoll_create:创建一个epoll对象,epollfd = epoll_create()。返回一个文件描述符,这个文件描述符指向内核中的事件表,这个事件表存放的是用户关心的文件描述符。相当于使用一个文件描述符来管理用户的所有的文件描述符。调用epoll_create方法会向epoll向内核注册的文件系统里面创建一个file节点。并且在属于epoll的高速缓存区中建立一个红黑树用于存储epoll_ctl传来的socket描述符。最后还会建立一个就绪的链表,用于存储准备就绪的事件。(文件系统和缓存区直接采用mmap来添加映射关系)
int epoll_ctl:操作epoll_create建立的epoll,可将新建的socket描述符加入到epoll让其监控,或者移除正在被监控的某个socket描述符。返回值:若成功,返回0,若出错返回-1。epoll_ctl把socket描述符添加到红黑树上,还会给内核的中断处理程序注册一个回调函数,这样内核会在此socket描述符中断到了后,就把它放到就绪的链表中。(中断:网卡上收到了数据,从CPU发送一个中断请求,CPU会从当前正在执行的动作中抽出身来获取数据,然后通过驱动程序通知操作系统,操作系统通知epoll在内核的中断处理程序上注册的回调函数把处理此socket描述符-->放到就绪链表中。)
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout):等待所监听文件描述符上有事件发生。返回值:若成功,返回就绪的文件描述符个数,若出错,返回-1,时间超时返回0。epoll_wait直接监控就绪链表里面有没有数据即可,有数据就返回,没有数据就sleep,如果过了timeout不管有无数据都直接返回,就不用去扫描所有的socket描述符(fd)。如果我们即使监控数百万的fd。那么在就绪链表里面也会只有很少的fd就绪,这样epoll_wait从内核的缓冲区复制在用户的缓冲区中是很少的数据,这也就是epoll_wait高效所在之一。
struct epoll_event *events:
struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
疑问:epoll_wait()方法的返回值的是fd就绪的个数,那么应用系统又是怎么知道具体是哪些fd就绪的?epoll不会去遍历整个fd。
解答:通过查看源码发现在epoll_wait方法中,会从通过就绪链表拿出就绪的事件,然后向用户空间发送data信息。if(__put_user(revents,&events[eventcnt.].events) || __put_user(epi->event.data,&events[eventcnt].data)):event.data就是epoll_data。epoll_data里面包含fd,fd就是可读或者可写的文件描述符。应用程序就知道哪个fd可读可写了。
epoll的执行过程:
1,执行epoll_create创建红黑树和就绪链表。2,执行epoll_ctl时,向红黑树增加socket描述符,并向内核注册回调函数,当中断事件发生后通过回调函数想就绪链表中写入数据。3,当事件(可读可写)发生epoll_wait被触发,直接向用户缓存区返回准备就绪的fd。
关键:1,红黑树高速的查找、插入、删除。2,就绪链表只保存准备就绪的描述符,减少从内核缓冲区到用户缓冲区的复制大小。
epoll对文件描述符有两种模式:LT(level trigger)和ET(edge trigger)LT是默认模式。
LT模式:采用LT模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理此事件,当下一次调用epoll_wait时,epoll_wait还会将此事件通告应用程序。
ET模式:当调用epoll_ctl,向参数event注册EPOLLET事件时,epoll将以ET模式来操作该文件描述符,ET模式是epoll的高效工作模式。对于采用ET模式的文件描述符,当epoll_wait检测到其上有事件发生并将此通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不在向应用程序通知这一事件。ET模式降低了同一epoll事件被触发的次数,效率比LT模式高。
总结:在select/poll中,进程调用select方法,将所监控的描述符从用户缓冲区复制到内核缓冲区中,将数以万计的socket描述符从用户缓冲区复制到内核缓冲区里面,这是非常低效的。内核对所有监视的文件描述符进行全部扫描。epoll事先通过epoll_ctl()把文件描述符增加到红黑树上,一旦某个文件描述符就绪时,内核会调用epoll_ctl在内核上注册的回调函数来将此描述符添加到就绪链表中,当进程调用epoll_wait()时就将就绪链表里的fd从内核缓冲区复制到用户的缓冲区,这次的copy是很少量的。
Java的IO模型可以参考:谈一谈 Java IO 模型 | Matt's Blog 里面说的比较好
参考资料:
阻塞和非阻塞,同步和异步 总结 - banananana - 博客园
关于同步、异步与阻塞、非阻塞的理解 - Anker's Blog - 博客园
Unix 网络 IO 模型及 Linux 的 IO 多路复用模型 | Matt's Blog
聊聊Linux 五种IO模型 -
聊聊IO多路复用之select、poll、epoll详解 -
epoll 或者 kqueue 的原理是什么? - 知乎