【业务学习】浅析服务器并发IO性能提高之路 — 从网络编程基础到epoll

baiyanphp

从网络编程基本概念提及

咱们经常使用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(静态进程数量)模式:socket

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

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

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

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

多线程

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

IO多路复用

前面谈到的都是经过增长进程和线程的数量来同时处理多个套接字。而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的遗留问题就所有解决啦。

总结

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