浅析服务器并发IO性能提升之路-从网络编程基础到epoll

30次阅读

共计 10163 个字符,预计需要花费 26 分钟才能阅读完成。

baiyan

从网络编程基本概念说起

我们常常使用 HTTP 协议来传输各种格式的数据,其实 HTTP 这个应用层协议的底层,是基于传输层 TCP 协议来实现的。TCP 协议仅仅把这些数据当做一串无意义的数据流来看待。所以,我们可以说:客户端与服务器通过在建立的连接上发送字节流来进行通信
这种 C / S 架构的通信机制,需要标识通信双方的网络地址和端口号信息。对于客户端来说,需要知道我的数据接收方位置,我们用网络地址和端口来唯一标识一个服务端实体;对于服务端来说,需要知道数据从哪里来,我们同样用网络地址和端口来唯一标识一个客户端实体。那么,用来唯一标识通信两端的数据结构就叫做套接字。一个连接可以由它两端的套接字地址唯一确定:

(客户端地址: 客户端端口号,服务端地址: 服务端端口号)

有了通信双方的地址信息之后,就可以进行数据传输了。那么我们现在需要一个规范,来规定通信双方的连接及数据传输过程。在 Unix 系统中,实现了一套套接字接口,用来描述和规范双方通信的整个过程。

  • socket():创建一个套接字描述符
  • connect():客户端通过调用 connect 函数来建立和服务器的连接
  • bind():告诉内核将 socket()创建的套接字与某个服务端地址与端口连接起来,后续会对这个地址和端口进行监听
  • listen():告诉内核,将这个套接字当成服务器这种被动实体来看待 (服务器是等待客户端连接的被动实体,而内核认为 socket() 创建的套接字默认是主动实体,所以才需要 listen()函数,告诉内核进行主动到被动实体的转换)
  • accept():等待客户端的连接请求并返回一个新的已连接描述符

最简单的单进程服务器

由于 Unix 的历史遗留问题,原始的套接字接口对地址和端口等数据封装并不简洁,为了简化这些我们不关注的细节而只关注整个流程,我们使用 PHP 来进行分析。PHP 对 Unix 的 socket 相关接口进行了封装,所有相关套接字的函数都被加上了 socket_前缀,并且使用一个资源类型的套接字句柄代替 Unix 中的文件描述符 fd。在下文的描述中,均用“套接字”代替 Unix 中的文件描述符 fd 进行阐述。一个 PHP 实现的简单服务器伪代码如下:

<?php

if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {echo '套接字创建失败';}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {echo '绑定地址与端口失败';}
if (socket_listen($listenSocket) === false) {echo '转换主动套接字到被动套接字失败';}
while (1) {if (($connSocket = socket_accept($listenSocket)) === false) {echo '客户端的连接请求还没有到达';} else {socket_close($listenSocket); // 释放监听套接字
        socket_read($connSocket);  // 读取客户端数据,阻塞
        socket_write($connSocket); // 给客户端返回数据,阻塞
        
    }
    socket_close($connSocket);
}

我们梳理一下这个简单的服务器创建流程:

  • socket_create():创建一个套接字,这个套接字就代表建立的连接上的一个端点。第一个参数 AF_INET 为使用的底层协议为 IPv4;第二个参数 SOCK_STREAM 表示使用字节流进行数据传输;第三个参数 SQL_TCP 代表本层协议为 TCP 协议。这里创建的套接字只是一个连接上的端点的一个 抽象 概念。
  • socket_bind():绑定这个套接字到一个具体的服务器地址和端口上,真正 实例化 这个套接字。参数就是你之前创建的一个抽象的套接字,还有你具体的网络地址和端口。
  • socket_listen():我们观察到只有一个函数参数就是之前创建的套接字。有些同学之前可能认为这一步函数调用完全没有必要。但是它告诉内核,我是一个服务器,将套接字转换为一个被动实体,其实是有很大的作用的。
  • socket_accept():接收客户端发来的请求。因为服务器启动之后,是不知道客户端什么时候有连接到来的。所以,需要在一个 while 循环中不断调用这个函数,如果有连接请求到来,那么就会返回一个新的套接字,我们可以通过这个新的套接字进行与客户端的数据通信,如果没有,就只能不断地进行循环,直到有请求到来为止。

注意,在这里我将套接字分为两类,一个是 监听套接字 ,一个是 连接套接字。注意这里对两种套接字的区分,在下面的讨论中会用到:

  • 监听套接字:服务器对某个端口进行监听,这个套接字用来表示这个端口($listenSocket)
  • 连接套接字:服务器与客户端已经建立连接,所有的读写操作都要在连接套接字上进行($connSocket)

那么我们对这个服务器进行分析,它存在什么问题呢?

一个这样的服务器进程只能同时处理一个客户端连接与相关的读写操作。因为一旦有一个客户端连接请求到来,我们对监听套接字进行 accept 之后,就开启了与该客户端的数据传输过程。在数据读写的过程中,整个进程被该客户端连接独占,当前服务器进程只能处理该客户端连接的读写操作,无法对其它客户端的连接请求进行处理。

IO 并发性能提升之路

由于上述服务器的性能太烂,无法同时处理多个客户端连接以及读写操作,所以优秀的开发者们想出了以下几种方案,用以提升服务器的效率,分别是:

  • 多进程
  • 多线程
  • 基于单进程的 IO 多路复用(select/poll/epoll)

多进程

那么如何去优化单进程呢?很简单,一个进程不行,那搞很多个进程不就可以同时处理多个客户端连接了吗?我们想了想,写出了代码:

<?php

if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {echo '套接字创建失败';}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {echo '绑定地址与端口失败';}
if (socket_listen($listenSocket) === false) {echo '转换主动套接字到被动套接字失败';}
for ($i = 0; $i < 10; $i++) { // 初始创建 10 个子进程
    if (pcntl_fork() == 0) {if (($connSocket = socket_accept($listenSocket)) === false) {echo '客户端的连接请求还没有到达';} else {socket_close($listenSocket); // 释放监听套接字
            socket_read($connSocket);  // 读取客户端数据
            socket_write($connSocket); // 给客户端返回数据
        }
        socket_close($connSocket);
    }
}

我们主要关注这个 for 循环,一共循环了 10 次代表初始的子进程数量我们设置为 10。接着我们调用了 pcntl_fork()函数创建子进程。由于一个客户端的 connect 就对应一个服务端的 accept。所以在每个 fork 之后的 10 个子进程中,我们均进行 accept 的系统调用,等待客户端的连接。这样,就可以通过 10 个服务器进程,同时接受 10 个客户端的连接、同时为 10 个客户端提供读写数据服务。
注意这样一个细节,由于所有子进程都是预先创建好的,那么请求到来的时候就不用创建子进程,也提高了每个连接请求的处理效率。同时也可以借助进程池的概念,这些子进程在处理完连接请求之后并不立即回收,可以继续服务下一个客户端连接请求,就不用重复的进行 fork()的系统调用,也能够提高服务器的性能。这些小技巧在 PHP-FPM 的实现中都有所体现。其实这种进程创建方式是其三种运行模式中的一种,被称作 static(静态进程数量)模式:

  • ondemand:按需启动。PHP-FPM 启动的时候不会启动任何一个子进程(worker 进程),只有客户端连接请求到达时才启动
  • dynamic:在 PHP-FPM 启动时,会初始启动一些子进程,在运行过程中视情况动态调整 worker 数量
  • static:PHP-FPM 启动时,启动固定大小数量的子进程,在运行期间也不会扩容

回到正题,多进程这种方式的的确确解决了服务器在同一时间只能处理一个客户端连接请求的问题,但是这种基于多进程的客户端连接处理模式,仍存在以下劣势:

  • fork()等系统调用会使得进程的上下文进行切换,效率很低
  • 进程创建的数量随着连接请求的增加而增加。比如 100000 个请求,就要 fork100000 个进程,开销太大
  • 进程与进程之间的地址空间是私有、独立的,使得进程之间的数据共享变得困难

既然谈到了多进程的数据共享与切换开销的问题,那么我们能够很快想到解决该问题的方法,就是化多进程为更轻量级的多线程。

多线程

线程是运行在进程上下文的逻辑流。一个进程可以包含多个线程,多个线程运行在单一的进程上下文中,因此共享这个进程的地址空间的所有内容,解决了进程与进程之间通信难的问题。同时,由于一个线程的上下文要比一个进程的上下文小得多,所以线程的上下文切换,要比进程的上下文切换效率高得多。线程是轻量级的进程,解决了进程上下文切换效率低的问题。
由于 PHP 中没有多线程的概念,所以我们仅仅把上面的伪代码中创建进程的部分,改成创建线程即可,代码大体类似,在此不再赘述。

IO 多路复用

IO 多路复用这个名词看起来好像很复杂很高深的样子。实际上,这项技术所能带来的本质成果就是:一个服务端进程可以同时处理多个套接字

  • 多路:多个客户端连接
  • 复用:使用单进程就能够实现同时处理多个客户端的连接

在之前的讲述中,一个服务端进程,只能同时处理一个连接。如果想同时处理多个客户端连接,需要多进程或者多线程的帮助,免不了上下文切换的开销。IO 多路复用技术就解决了上下文切换的问题。IO 多路复用技术的发展可以分为 select->poll->epoll 三个阶段。

IO 多路复用的核心就是添加了一个 套接字集合管理员 ,它可以 同时监听多个套接字。由于客户端连接以及读写事件到来的随机性,我们需要这个管理员在单进程内部对多个套接字的事件进行合理的调度。

select

最早的 套接字集合管理员 是 select()系统调用,它可以同时管理多个套接字。select()函数会在某个或某些套接字的状态从不可读变为可读、或不可写变为可写的时候通知服务器主进程。所以 select()本身的调用是阻塞的。但是具体哪一个套接字或哪些套接字变为可读或可写我们是不知道的,所以我们需要遍历所有 select()返回的套接字来判断哪些套接字可以进行处理了。而这些套接字中又可以分为 监听套接字 连接套接字 (上文提过)。我们可以使用 PHP 为我们提供的 socket_select() 函数。在 select()的函数原型中,为套接字们分了个类:读、写与异常套接字集合,分别监听套接字的读、写与异常事件。:

function socket_select (array &$read, array &$write, array &$except, $tv_sec, $tv_usec = 0) {}

举个例子,如果某个客户单通过调用 connect()连接到了服务器的 监听套接字 ($listenSocket)上,这个监听套接字的状态就会从不可读变为可读。由于监听套接字只有一个,select() 对于监听套接字上的处理仍然是阻塞的。一个监听套接字,存在于整个服务器的生命周期中,所以在 select()的实现中并不能体现出其对监听套接字的优化管理。
在当一个服务器使用 accept()接受多个客户端连接,并生成了多个 连接套接字 之后,select()的管理才能就会体现出来。这个时候,select()的监听列表中有 一个监听套接字 、和与 一堆 客户端建立连接后新创建的 连接套接字 。在这个时候,可能这一堆已建立连接的客户端,都会通过这个连接套接字发送数据,等待服务端接收。假设同时有 5 个连接套接字都有数据发送,那么这 5 个连接套接字的状态都会变成可读状态。由于已经有套接字变成了可读状态,select() 函数解除阻塞,立即返回。具体哪一个套接字或哪些套接字变为可读或可写我们是不知道的,所以我们需要遍历所有 select()返回的套接字,来判断哪些套接字已经就绪,可以进行读写处理。遍历完毕之后,就知道有 5 个连接套接字可以进行读写处理,这样就实现了同时对多个套接字的管理。使用 PHP 实现 select()的代码如下:

<?php
if (($listenSocket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))=== false) {echo '套接字创建失败';}
if (socket_bind($listenSocket, '127.0.0.1', 8888) === false) {echo '绑定地址与端口失败';}
if (socket_listen($listenSocket) === false) {echo '转换主动套接字到被动套接字失败';}

/* 要监听的三个 sockets 数组 */
$read_socks = array(); // 读
$write_socks = array(); // 写
$except_socks = NULL; // 异常

$read_socks[] = $listenSocket; // 将初始的监听套接字加入到 select 的读事件监听数组中

while (1) {/* 由于 select()是引用传递,所以这两个数组会被改变,所以用两个临时变量 */
    $tmp_reads = $read_socks;
    $tmp_writes = $write_socks;
    $count = socket_select($tmp_reads, $tmp_writes, $except_socks, NULL);
    foreach ($tmp_reads as $read) { // 不知道哪些套接字有变化,需要对全体套接字进行遍历来看谁变了
        if ($read == $listenSocket) { // 监听套接字有变化,说明有新的客户端连接请求到来
            $connSocket = socket_accept($listenSocket);  // 响应客户端连接,此时一定不会阻塞
            if ($connSocket) {
                // 把新建立的连接 socket 加入监听
                $read_socks[] = $connSocket;
                $write_socks[] = $connSocket;}
        } else { // 新创建的连接套接字有变化
            /* 客户端传输数据 */
            $data = socket_read($read, 1024);  // 从客户端读取数据, 此时一定会读到数据,不会产生阻塞
            if ($data === '') { // 已经无法从连接套接字中读到数据,需要移除对该 socket 的监听
                foreach ($read_socks as $key => $val) {if ($val == $read) unset($read_socks[$key]); // 移除失效的套接字
                }
                foreach ($write_socks as $key => $val) {if ($val == $read) unset($write_socks[$key]);
                }
                socket_close($read);
            } else { // 能够从连接套接字读到数据。此时 $read 是连接套接字
                if (in_array($read, $tmp_writes)) {socket_write($read, $data);// 如果该客户端可写 把数据写回到客户端
                }
            }
        }
    }
}
socket_close($listenSocket);

但是,select()函数本身的调用阻塞的。因为 select()需要一直等到有状态变化的套接字之后(比如监听套接字或者连接套接字的状态由不可读变为可读),才能解除 select()本身的阻塞,继续对读写就绪的套接字进行处理。虽然这里是阻塞的,但是它能够同时返回多个就绪的套接字,而不是之前单进程中只能够处理一个套接字,大大提升了效率
总结一下,select()的过人之处有以下几点:

  • 实现了对多个套接字的同时、集中管理
  • 通过遍历所有的套接字集合,能够获取所有已就绪的套接字,对这些就绪的套接字进行操作不会阻塞

但是,select()仍存在几个问题:

  • select 管理的套接字描述符们存在数量限制。在 Unix 中,一个进程最多同时监听 1024 个套接字描述符
  • select 返回的时候,并不知道具体是哪个套接字描述符已经就绪,所以需要遍历所有套接字来判断哪个已经就绪,可以继续进行读写

为了解决第一个套接字描述符数量限制的问题,聪明的开发者们想出了 poll 这个新套接字描述符管理员,用以替换 select 这个老管理员,select()就可以安心退休啦。

poll

poll 解决了 select 带来的套接字描述符的最大数量限制问题。由于 PHP 的 socket 扩展没有 poll 对应的实现,所以这里放一个 Unix 的 C 语言原型实现:

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

poll 的 fds 参数集合了 select 的 read、write 和 exception 套接字数组,合三为一。poll 中的 fds 没有了 1024 个的数量限制。当有些描述符状态发生变化并就绪之后,poll 同 select 一样会返回。但是遗憾的是,我们同样不知道具体是哪个或哪些套接字已经就绪,我们仍需要遍历套接字集合去判断究竟是哪个套接字已经就绪,这一点并没有解决刚才提到 select 的第二个问题。
我们可以总结一下,select 和 poll 这两种实现,都需要在返回后,通过遍历所有的套接字描述符来获取已经就绪的套接字描述符。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
为了解决不知道返回之后究竟是哪个或哪些描述符已经就绪的问题,同时避免遍历所有的套接字描述符,聪明的开发者们又发明出了 epoll 机制,完美解决了 select 和 poll 所存在的问题。

epoll

epoll 是最先进的套接字们的管理员,解决了上述 select 和 poll 中所存在的问题。它将一个阻塞的 select、poll 系统调用拆分成了三个步骤。一次 select 或 poll 可以看作是由一次 epoll_create、若干次 epoll_ctl、若干次 epoll_wait 构成:

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  • epoll_create():创建一个 epoll 实例。后续操作会使用
  • epoll_ctl():对套接字描述符集合进行增删改操作,并告诉内核需要监听套接字描述符的什么事件
  • epoll_wait():等待监听列表中的 连接事件 (监听套接字描述符才会发生)或 读写事件(连接套接字描述符才会发生)。如果有某个或某些套接字事件已经准备就绪,就会返回这些已就绪的套接字们

看起来,这三个函明明就是从 select、poll 一个函数拆成三个函数了嘛。我们对某套接字描述符的添加、删除、修改操作由之前的代码实现变成了调用 epoll_ctl()来实现。epoll_ctl()的参数含义如下:

  • epfd:epoll_create()的返回值
  • op:表示对下面套接字描述符 fd 所进行的操作。EPOLL_CTL_ADD:将描述符添加到监听列表;EPOLL_CTL_DEL:不再监听某描述符;EPOLL_CTL_MOD:修改某描述符
  • fd:上面 op 操作的套接字描述符对象(之前在 PHP 中是 $listenSocket 与 $connSocket 两种套接字描述符)例如将某个套接字 添加 到监听列表中
  • event:告诉内核需要监听该套接字描述符的什么事件(如读写、连接等)

最后我们调用 epoll_wait()等待连接或读写等事件,在某个套接字描述符上准备就绪。当有事件准备就绪之后,会存到第二个参数 epoll_event 结构体中。通过访问这个结构体就可以得到所有已经准备好事件的套接字描述符。这里就不用再像之前 select 和 poll 那样,遍历所有的套接字描述符之后才能知道究竟是哪个描述符已经准备就绪了,这样减少了一次 O(n)的遍历,大大提高了效率。
在最后返回的所有套接字描述符中,同样存在之前说过的两种描述符:监听套接字描述符 连接套接字描述符。那么我们需要遍历所有准备就绪的描述符,然后去判断究竟是监听还是连接套接字描述符,然后视情况做做出 accept(监听套接字)或者是 read(连接套接字)的处理。一个使用 C 语言编写的 epoll 服务器的伪代码如下(重点关注代码注释):

int main(int argc, char *argv[]) {listenSocket = socket(AF_INET, SOCK_STREAM, 0); // 同上,创建一个监听套接字描述符
    
    bind(listenSocket)  // 同上,绑定地址与端口
    
    listen(listenSocket) // 同上,由默认的主动套接字转换为服务器适用的被动套接字
    
    epfd = epoll_create(EPOLL_SIZE); // 创建一个 epoll 实例
    
    ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE); // 创建一个 epoll_event 结构存储套接字集合
    event.events = EPOLLIN;
    event.data.fd = listenSocket;
    
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenSocket, &event); // 将监听套接字加入到监听列表中
    
    while (1) {event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1); // 等待返回已经就绪的套接字描述符们
        
        for (int i = 0; i < event_cnt; ++i) { // 遍历所有就绪的套接字描述符
            if (ep_events[i].data.fd == listenSocket) { // 如果是监听套接字描述符就绪了,说明有一个新客户端连接到来
            
                connSocket = accept(listenSocket); // 调用 accept()建立连接
                
                event.events = EPOLLIN;
                event.data.fd = connSocket;
                
                epoll_ctl(epfd, EPOLL_CTL_ADD, connSocket, &event); // 添加对新建立的连接套接字描述符的监听,以监听后续在连接描述符上的读写事件
                
            } else { // 如果是连接套接字描述符事件就绪,则可以进行读写
            
                strlen = read(ep_events[i].data.fd, buf, BUF_SIZE); // 从连接套接字描述符中读取数据, 此时一定会读到数据,不会产生阻塞
                if (strlen == 0) { // 已经无法从连接套接字中读到数据,需要移除对该 socket 的监听
                
                    epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); // 删除对这个描述符的监听
                    
                    close(ep_events[i].data.fd);
                } else {write(ep_events[i].data.fd, buf, str_len); // 如果该客户端可写 把数据写回到客户端
                }
            }
        }
    }
    close(listenSocket);
    close(epfd);
    return 0;
}

我们看这个通过 epoll 实现一个 IO 多路复用服务器的代码结构,除了由一个函数拆分成三个函数,其余的执行流程基本同 select、poll 相似。只是 epoll 会只返回已经就绪的套接字描述符集合,而不是所有描述符的集合,IO 的效率不会随着监视 fd 的数量的增长而下降,大大提升了效率。同时它细化并规范了对每个套接字描述符的管理(如增删改的过程)。此外,它监听的套接字描述符是没有限制的,这样,之前 select、poll 的遗留问题就全部解决啦。关于 epoll 的两种工作工作模式有 LT(水平触发)和 ET(边缘触发)并不是我们此次的重点,并且我们在讲述的过程中省略了描述符在内核空间与与用户空间的拷贝过程,以简化我的表述。有兴趣的同学可以搜索其他博客进行扩展学习。

总结

我们从最基本网络编程说起,开始从一个最简单的同步阻塞服务器到一个 IO 多路复用服务器,我们从头到尾了解到了一个服务器性能提升的思考与实现过程。而提升服务器的并发性能的方式远不止这几种,还包括协程等新的概念需要我们去对比与分析,大家加油。

正文完
 0