并发编程与 IO 复用

对于服务器的并发处理能力,我们需要的是:每一毫秒服务器都能及时处理这一毫秒内收到的数百个不同 TCP 连接上的报文,与此同时,可能服务器上还有数以十万计的最近几秒没有收发任何报文的相对不活跃连接。

同时处理多个并行发生事件的连接,简称为并发;同时处理万计、十万计的连接,则是高并发。服务器的并发编程所追求的就是处理的并发连接数目无限大,同时维持着高效率使用 CPU 等资源,直至物理资源首先耗尽。

并发编程有很多种实现模型,最简单的就是与“线程”捆绑,1 个线程处理 1 个连接的全部生命周期。
优点:
这个模型足够简单,又可以实现复杂的业务场景,同时,线程个数是可以远大于 CPU 个数的。
缺点:
如果操作系统的线程总数很多时,来回唤醒、睡眠的代价就是昂贵的,因为这种技术性的调度损耗会影响到线程执行业务代码的时间。举个例子,不活跃连接的线程就像国企,它们执行效率太低了,总是唤醒就睡眠在做无用功,而它唤醒争到 CPU 资源的同时,就意味着活跃连接的其他线程减少了获得 CPU 的机会。

连接上的消息处理,可以分为两个阶段:等待消息准备好、消息处理。当使用默认的阻塞套接字时(例如上面提到的 1 个线程捆绑处理 1 个连接),往往是把这两个阶段合二为一,这样操作套接字的代码所在的线程就得睡眠来等待消息准备好,这导致了高并发下线程会频繁的睡眠、唤醒,从而影响了 CPU 使用效率。

目前对于高并发编程只有一种模型,也是本质上唯一有效的办法。这就是 IO 多路复用了。

多路复用就是处理等待消息准备好这件事的,但它可以同时处理多个连接。它也可能“等待”,导致线程睡眠,然而不要紧,因为它一对多、可以监控所有连接。这样,当我们的线程被唤醒执行时,就一定是有一些连接准备好被我们的代码执行了,这是有效率的。没有那么多个线程都在争抢处理“等待消息准备好”阶段。

IO 复用的使用大概是这样:
          设置要监听的描述符以及需要监听的事件
                   ↓
           监听事件,一直阻塞直到有描述符就绪
                   ↓
               处理就绪的描述符

多路复用有很多种实现,在 linux 上,2.4 内核前主要是 select 和 poll,现在主流是 epoll。

select

函数原型:

int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);

参数详解:

nfds 指定被监听的文件描述符的总数,一般设置成最大的描述符加上 1,因为描述符是从 0 开始的
readfds 需要监听读事件的描述符集
writefds 需要监听写事件的描述符集
exceptfds 需要监听异常事件的描述符集
timeout 超时时间,如果传递的是 NULL,则 select 会一直阻塞

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。

一般的使用步骤:

  1. 设置 fd_set,如果要同时监听多种事件,则需要使用多个描述符集合
  2. 调用 select,select 会通过更改描述集,只留下准备好的描述符,所以需要在调用前保留一份
  3. 通过遍历数组和 FD_ISSET 来判断是否准备好,若准备好则去执行相关任务

优点:
select() 的可移植性更好,在某些 Unix 系统上不支持 poll();
select() 对于超时值提供了更好的精度——微秒,而 poll() 是毫秒。

缺点:
单个进程可监视的 fd 数量被限制,一般是 1024;
需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大;
对 fd 进行扫描时是线性扫描。fd 剧增后,IO 效率较低;
select() 函数的超时参数在返回时是未定义的,考虑到可移植性,每次在超时之后在下一次进入到 select() 之前都需要重新设置超时参数。

poll

poll 相对于 select 的优点就是解决了描述符的限制问题,但是性能并不好,也是需要通过遍历的方式来判断描述符的准备状态。

函数原型:

int poll(struct pollfd *fds, nfd_t nfds, int timeout);

参数详解:

struct pollfd {
    int fd;    //文件描述符
    short events;     //注册的事件
    short revents;    //实际发生的事件
}
fds 需要监听的数组, 数组里的每个结构体都设置好描述符和需要注册的事件, 如果有多个, 使用或('|')操作符
nfds 数组的长度
timeout 超时值,单位是毫秒

不同于 select 使用三个位图来表示三个 fdset 的方式,poll 使用一个 pollfd 的指针实现。

select() 和 poll() 将就绪的文件描述符告诉进程后,如果进程没有对其进行 IO 操作,那么下次调用 select() 和 poll() 的时候将再次报告这些文件描述符,所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。

优点:
没有最大文件描述符数量的限制;

缺点:
描述符数组复制开销和轮询开销依然很大。

epoll

epoll 能显著减少程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率,因为它不会复用文件描述符集合来传递结果而迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合。

另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核 IO 事件异步唤醒而加入 Ready 队列的描述符集合就行了。

还增加了一些新的特性,比如 ET(Edge Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,只说一遍,如果没有采取行动,那么它将不会再次告知,这种方式称为边缘触发)和 LT 两种触发模式。

epoll 提供了三个函数:

// 创建一个描述符, 指向内核创建的描述符集
// 之后的操作都通过描述符来操作
int epoll_create(int size);

// 先注册要监听的事件类型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd 指向描述符集的描述符
op 有三个宏:
EPOLL_CTL_ADD 添加 fd 上的注册事件
EPOLL_CTL_MOD 修改 fd 上的注册事件
EPOLL_CTL_DEL 删除 fd 上的注册事件
fd 需要操作的描述符
struct epoll_event {
__uint32_t events;    // epoll事件
epoll_data_t data;    // 用户数据
}
*/

// 等待事件的产生, 类似于 select() 调用
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
/*
返回准备好的描述符的个数
epfd 指向描述符集的描述符
events 作为函数传出使用, epoll_wait 之后会设置这个数组, 之后再遍历这个数组即可
maxevents 指定最多监听的描述符的个数
timeout 超时时间, 和 poll 相同
*/

底层实现
epoll 在底层实现了自己的高速缓存区,并且建立了一个红黑树用于存放 socket,另外维护了一个链表用来存放准备就绪的事件。

图中左下方的红黑树由所有待监控的连接构成。左上方的链表,同是目前所有活跃的连接。于是,epoll_wait() 执行时只是检查左上方的链表,并返回左上方链表中的连接给用户。

工作过程:
执行 epoll_ create() 时,创建了红黑树和就绪链表,执行 epoll_ ctl() 时,如果增加 socket() 句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

优点:
支持一个进程打开大数目的描述符;
IO 效率不随 fd 数目增加而线性下降;
使用 mmap 加速内核与用户空间的消息传递。

mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read/write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

你可能感兴趣的:(并发编程与 IO 复用)