BIO
int main()
{
int sk = socket(AF_INET, SOCK_STREAM, 0);
connect(sk, ...)
recv(sk, ...)
}
复制代码
高并发的服务器开发中,这种网络 IO 的性能奇差。因为
1.进程在 recv 的时候大概率会被阻塞掉,导致一次进程切换(数据还没有到)
2.当连接上数据就绪的时候进程又会被唤醒,又是一次进程切换(数据被拷贝到socket接受缓冲区时候唤醒)
3.一个进程同时只能等待一条连接,如果有很多并发,则需要很多进程(线程被阻塞在socket的等待队列)
在上面的 demo 中虽然只是简单的两三行代码,但实际上用户进程和内核配合做了非常多的工作。先是用户进程发起创建 socket 的指令,然后切换到内核态完成了内核对象的初始化。接下来 Linux 在数据包的接收上,是硬中断和 ksoftirqd 进程在进行处理。当 ksoftirqd 进程处理完以后,再通知到相关的用户进程。
一、创建一个 socket
socket 函数调用执行完以后,内核在内部创建了一系列的 socket 相关的内核对象(是的,不是只有一个)
当软中断上收到数据包时会通过调用 sk_data_ready 函数指针(实际被设置成了 sock_def_readable()) 来唤醒在 sock 上等待的进程。这个咱们后面介绍软中断的时候再说,这里记住这个就行了。
至此,一个 tcp对象,确切地说是 AF_INET 协议族下 SOCK_STREAM对象就算是创建完成了。这里花费了一次 socket 系统调用的开销
二、等待接收消息
-
recv 函数底层会执行 recvfrom 的系统调用
-
系统调用之后用户进程进入内核态,去查看对应的socket 是否存在数据,如果没有,就将自己阻塞到socket 的等待队列中,让出CPU,等待被唤醒
三、软中断模块
软中断模块将数据放到 socket 的接受队列后,通过回调函数唤醒等待队列上的进程,该进程进入就绪状态,等待cpu的调用
四、小结
第一部分是我们自己代码所在的进程,我们调用的 socket() 函数会进入内核态创建必要内核对象。 recv() 函数在进入内核态以后负责查看接收队列,以及在没有数据可处理的时候把当前进程阻塞掉,让出 CPU。
第二部分是硬中断、软中断上下文(系统进程 ksoftirqd)。在这些组件中,将包处理完后会放到 socket 的接收队列中。然后再根据 socket 内核对象找到其等待队列中正在因为等待而被阻塞掉的进程,然后把它唤醒。
每次一个进程专门为了等一个 socket 上的数据就得被从 CPU 上拿下来。然后再换上另一个进程。等到数据 ready 了,睡眠的进程又会被唤醒。总共两次进程上下文切换开销,根据之前的测试来看,每一次切换大约是 3-5 us(微秒)左右。如果是网络 IO 密集型的应用的话,CPU 就不停地做进程切换这种无用功。
select 和 epoll
epoll与select、poll的对比
1、 用户态将文件描述符传入内核的方式
select:
创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程(select 线程) 可以打开的fd数量限制,默认是1024。
poll:
将传入的struct pollfd结构体数组拷贝到内核中进行监听。
epoll:
执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。
2、内核态检测文件描述符读写状态的方式
select:
采用轮询方式,遍历所有fd, 最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值。
poll:
同样采用轮询方式,查询每个fd的状态, 如果就绪则在等待队列中加入一项并继续遍历。
epoll:
采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。
3、找到就绪的文件描述符并传递给用户态的方式
select:
将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。(这里遍历的时候,发现一个就会-1)
poll:
将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
epoll:
epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。
这里返回的文件描述符是通过mmap
让内核和用户空间共享同一块内存实现传递的,减少了不必要的拷贝。
4、重复监听的处理方式
select:
将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。
poll:
将新的struct pollfd结构体数组拷贝传入内核中,继续以上步骤。
epoll:
无需重新构建红黑树,直接沿用已存在的即可。
调用代码
int main(){
listen(lfd, ...);
cfd1 = accept(...);
cfd2 = accept(...);
efd = epoll_create(...);
epoll_ctl(efd, EPOLL_CTL_ADD, cfd1, ...);
epoll_ctl(efd, EPOLL_CTL_ADD, cfd2, ...);
epoll_wait(efd, ...)
}
复制代码
一、eventpoll 内核对象
将这张图联合上面那张图一起看,可以发现在创建了 2 个socket对象(5000和5001)之后,创建了一个 eventepoll对象(5003)
eventpoll 这个结构体中的几个成员的含义如下:
wq:
等待队列链表。软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
rbr:
一棵红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用了一棵红黑树。通过这棵树来管理用户进程下添加进来的所有 socket 连接。
rdllist:
就绪的描述符的链表。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历整棵树。
二、epoll_ctl 添加 socket
在使用 epoll_ctl
注册每一个 socket 的时候,内核会做如下三件事情
1.分配一个红黑树节点对象 epitem,
2.添加等待事件到 socket 的等待队列中,其回调函数是 ep_poll_callback
3.将 epitem 插入到 epoll 对象的红黑树里
通过 epoll_ctl 添加两个 socket 以后,这些内核数据结构最终在进程中的关系图大致如下:
1.分配并初始化 epitem
对于每一个 socket,调用 epoll_ctl 的时候,都会为之分配一个 epitem
这个很好理解, socket 对应一个 epitem,epitem 在 eventepoll 的红黑树上保存。
2. 设置 socket 等待队列
当将 socket 封装注册到 eventpoll 内核对象上之后,会在 socket 的等待队列注册一个回调函数,这里回调函数将会由软中断处理函数进行调用,目的是唤醒在 eventepoll 等待队列上的线程,并将
3 插入红黑树
分配完 epitem 对象后,紧接着并把它插入到红黑树中。一个插入了一些 socket 描述符的 epoll 里的红黑树的示意图如下:
选择红黑树,主要原因是在 查找、删除、插入效率上面比较均衡
三 epoll_wait 等待接收
epoll_wait 做的事情不复杂,当它被调用时它观察 eventpoll->rdllist 链表里有没有数据即可。有数据就返回,没有数据就创建一个等待队列项,将其添加到 eventpoll 的等待队列上,然后把自己阻塞掉就完事。
四 数据来了
流程主要是,软中断处理程序将数据放入 socket 的接受队列后,调用等待队列上的回调函数,先将 socket 对应的 epitem 添加到 epoll 的就绪队列上,然后去唤醒 epoll 等待队列上阻塞的用户线程,用户线程只需要遍历 就绪队列上的 epitem,就可以找到由数据存在 socket 对象,避免了扫描全部的socket。
五 小结
总结下,epoll 相关的函数里内核运行环境分两部分:
用户进程内核态。进行调用 epoll_wait 等函数时会将进程陷入内核态来执行。这部分代码负责查看接收队列,以及负责把当前进程阻塞掉,让出 CPU。
硬软中断上下文。在这些组件中,将包从网卡接收过来进行处理,然后放到 socket 的接收队列。对于 epoll 来说,再找到 socket 关联的 epitem,并把它添加到 epoll 对象的就绪链表中。这个时候再捎带检查一下 epoll 上是否有被阻塞的进程,如果有唤醒之。
为了介绍到每个细节,本文涉及到的流程比较多,把阻塞都介绍进来了。
但其实在实践中,只要活儿足够的多,epoll_wait 根本都不会让进程阻塞。用户进程会一直干活,一直干活,直到 epoll_wait 里实在没活儿可干的时候才主动让出 CPU。这就是 epoll 高效的地方所在!
六 select 原理
select本质上是通过设置或检查存放fd标志位的数据结构进行下一步处理。 这带来缺点: – 单个进程可监视的fd数量被限制,即能监听端口的数量有限 单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试 一般该数和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认1024个,64位默认2048。
(1)使用copy_from_user从用户空间拷贝fd_set到内核空间
(2)注册回调函数__pollwait
(3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
(4)以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
(5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
(8)把fd_set从内核空间拷贝到用户空间。
七 边缘触发和水平触发
EPOLLLT和EPOLLET两种:
LT,默认的模式(水平触发): 只要该fd还有数据可读,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作,
ET是“高速”模式(边缘触发):只会提示一次,直到下次再有数据流入之前都不会再提示,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读完,即读到read返回值小于请求值或遇到EAGAIN错误
epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似回调机制激活该fd,epoll_wait便可收到通知。
EPOLLET触发模式的意义
若用EPOLLLT,系统中一旦有大量无需读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这大大降低处理程序检索自己关心的就绪文件描述符的效率。 而采用EPOLLET,当被监控的文件描述符上有可读写事件发生时,epoll_wait会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait时,它不会通知你,即只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
八 优点
Epoll最大的优点就在于它只关心“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll
内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
epoll通过内核和用户空间共享一块内存来实现的
参考
图解 | 深入理解高性能网络开发路上的绊脚石 – 同步阻塞网络 IO (??新)
图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的!