初探五种服务器网络编程模型

本文详细探讨了五种服务器网络编程模型:同步阻塞迭代模型、多进程并发模型、多线程并发模型、I/O多路复用模型(select/poll)以及I/O多路复用模型之epoll。通过对比分析,阐述了各模型的工作原理、优缺点,特别是epoll的高效机制,如红黑树和回调机制,适合处理大量并发连接。
摘要由CSDN通过智能技术生成

五种服务器网络编程模型

首先来看看 Linux 上可以使用的 I/O 模型,下图是基本 Linux I/O 模型的简单矩阵,了解这些 I/O 相关知识能有助于理解后面的网络模型。
这里写图片描述

1.同步阻塞迭代模型

首先介绍同步阻塞迭代模型,它的核心代码如下:

bind(srvfd, ...);  
listen(srvfd, ...);  
for(;;){  
    clifd = accept(srvfd, ...); //开始接受客户端来的连接  
    read(clifd,buf, ...);       //从客户端读取数据,一直阻塞,直到读取到数据才返回
    dosomthingonbuf(buf);    
    write(clifd, buf, ...)          //发送数据到客户端  
}  

作为最简单直接的一种 IO模型,它存在以下弊端:

1、如果没有客户端的连接请求,进程会阻塞在 accept() 里,然后被 CPU 挂起。
2、在与客户端建立好连接后,通过 read() 从客户端接受数据,而客户端合适发送数据过来是不可控的。如果客户端迟迟不发生数据过来,缓冲区没有数据可读,那么程序同样会**阻塞**在 read() 中,此时,如果另外的客户端来尝试连接时,程序都不会响应。
3、同样的道理,write() 也会使得程序出现阻塞(例如:客户端接收数据异常缓慢,服务器端发生数据速度也会很慢,导致写缓冲区满,write() 则一直阻塞知道将数据成功写入缓冲区则返回)。

2.多进程并发模型

再来介绍一种可以同时接受多个连接请求的模型,它在同步阻塞迭代模型的基础上,引入了多进程。
核心代码:

bind(srvfd, ...);  //绑定套接字的本地地址
listen(srvfd, ...);  //监听
for(;;){  
    clifd = accept(srvfd, ...); //父进程开始接受来自客户端连接  
    ret = fork();  //申请进程
    switch (ret)  
    {  
      case -1 :  
        do_err_handler();  
        break;  
      case 0  :   // 返回 0 则为子进程  
        client_handler(clifd);  
        break;  
      default :   // 返回正整数则为父进程,返回值为子进程 ID
        close(clifd);  
        continue;   
    }  
}
// 子进程处理逻辑
void client_handler(clifd){  
    read(clifd, buf, ...);       //从客户端读取数据  
    dosomthingonbuf(buf);    
    write(clifd, buf, ...)          //发送数据到客户端  
}  

在单个进程中,也同样存在第一个模型的问题,一个进程同时只能处理一个请求,该方法通过“主进程负责监听,而多个子进程负责处理”的方式来获得并发,此模式属于fork and execute模式,性能也非常低下。

3、多线程并发模型

在多进程并发模型中,每 accept() 到一个客户端连接 fork() 一个进程,虽然Linux中引入了写实拷贝机制(Copy On Write,子进程继承了父进程的页面,它们在创建后是共享同一片内存的,直到发生了写才为子进程分配独立的内存空间),大大降低了 fork() 一个子进程的消耗,但若客户端连接较大,则系统依然将不堪负重。通过多线程(以及线程池)的并发模型,可以在一定程度上改善这一问题。

在服务端的线程模型实现方式一般有三种:

1、按需生成(thread per request,来一个连接生成一个线程,与第二种模型类似)
2、线程池(预先生成很多线程)
3、Leader Follower(LF)

为简单起见,以第一种为例,其核心代码如下:

// 线程回调函数,新创建的线程用来处理请求  
void *thread_callback(void *args) {
    int clifd = *(int *)args;
    client_handler(clifd);  
}  

//
void client_handler(clifd) {  
    read(clifd, buf, ...);  // 从客户端读取数据  
    dosomthingonbuf(buf);
    write(clifd, buf, ...);  // 发送数据到客户端 
}  

srvfd = socket(...);
bind(srvfd, ...);
listen(srvfd, ...);
for(;;){  
    clifd = accept();  // worker线程接收客户端连接
    pthread_create(..., thread_callback, &clifd);  
}

服务端分为master线程和worker线程,master线程负责accept()连接,而worker线程负责处理业务逻辑和流的读取等(这主进程和子进程的工作模式一样)。因此,即使在worker线程阻塞的情况下,也只是阻塞在一个线程范围内,对master进程继续接受新的客户端连接不会有影响。

第二种实现方式,通过线程池的引入可以避免频繁的创建、销毁线程的过程,能在很大程序上提升性能。

但不管如何实现,多线程模型先天具有如下缺点:

  • 稳定性相对较差。一个线程的崩溃会导致整个进程崩溃。(这个问题可以使用多进程+多线程解决)
  • 临界资源的访问控制。在加大程序复杂性的同时,锁机制的引入会是严重降低程序的性能。性能上可能会出现“辛辛苦苦好几年,一夜回到解放前”的情况。

4.I/O多路复用模型之 select/poll

多进程模型和多线程模型每个进程/线程同一时间内只能处理一路I/O,在服务器并发数较高的情况下,过多的进程/线程会使得服务器性能下降。而通过多路I/O复用,能使得一个进程/线程同时处理多路I/O,提升服务器吞吐量。

在 Linux 支持 epoll 模型之前,都使用 select/poll 模型来实现 I/O 的多路复用。

以 select 为例,其核心代码如下,这里只讨论监听符准备好读时的情况:

int select(int n, fd_set *readset, fd_set *writeset, fd_set *excepser, const struct timeval *timeout);

FD_ZERO(fd_set *fd_set);
FD_CLR(int fd, fd_set *fdset);
FD_SET(int fd, fd_set *fdset);
FD_ISSET(int fd, fd_set *fdset);  //  比特位为1则表示描述符活跃

listenfd = socket(...);
bind(listenfd, ...);  
listen(listenfd, ...);  
FD_ZERO(&allset);  //清空 allset
FD_SET(listenfd, &allset);  //  将监听描述符加入 allset,当有连接请求时活跃。fd_set 类似数组,相当于将 fd_set[listenfd] 从 0 变为 1
for(;;){  
    /* 不断调用,每次调用会将保存了所有 fd 的 fd_set 复制到内核空间中,通过内核轮训 fd_set 来检测是否有事件发生。这里我们将 timeout 设为 NULL select()会一直阻塞,直到有事件发生 */
    rset = allset;
    select(listenfd+1, &rset, NULL, NULL, NULL); 
    //这个for循环(轮询)用来处理可读的文件描述符
    for(;;){
    //有新的客户端连接来时, listenfd 变为可读状态
        fd = cliarray[i];  
        if (fd == listenfd && FD_ISSET(fd, &rset)) {   
            clifd = accept();  
            cliarray[] = clifd;       //保存新的连接套接字
            FD_SET(clifd, &allset);   //将已连接描述符加入监听数组中,
        }      
        //  其他监听描述符可读时
        if (FD_ISSET(fd , &rset))  
            dosomething();
        //  没有可读时 break;
    }  
}  

select() 实现的IO多路复用同样存在以下缺点:

  • 1、单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于 select()是采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024)
  • 2、内核/用户空间内存拷贝问题,select()需要复制传递大量的句柄数据结构,产生巨大的开销;
  • 3、select()返回的是含有所有句柄的 fd_set(类似数组),应用程序需要通过遍历来确认才能知道哪些描述符是准备好的。
  • 4、select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行 I/O 操作,那么之后每次 select() 后,该文件描述符总是就绪的。

相比 select 模型使用类似数组的 fd_set 来保存描述符,poll 使用链表保存描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在。

拿select模型为例,假设我们的服务器需要支持100万的并发连接,则在 __FD_SETSIZE 为 1024 的情况下,则我们至少需要开辟1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的无脑内存数据拷贝、内核数组轮询等,是系统难以承受的。

5.I/O 多路复用模型之 epoll

由于epoll的实现机制与select/poll机制完全不同,上面所说的 select的缺点在epoll上不复存在。

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?

在 select/poll 时代,服务器进程不断调用 select() 把这100万个连接告诉操作系统(从用户内存空间复制句柄数据结构到内核内存空间),让操作系统内核去轮询数组里的这些套接字上是否有事件发生,轮询完后,再将套接字相关数据复制到用户空间,让服务器应用程序处理就绪的描述符,这一过程资源消耗较大,因此,select/poll 一般只能处理几千的并发连接。

epoll 的设计和实现与select完全不同。epoll 通过在 Linux 内核内存空间中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+Tree)。把 select/poll 一步完成的工作划分为三步:

  • 调用epoll_create()产生epoll句柄
  • 调用epoll_ctl()向epoll对象注册事件
  • 调用epoll_wait()返回发生的事件的连接

如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除套接字描述符。

下面来看看Linux内核具体的epoll机制实现思路。

当某一进程调用 epoll_create()时,Linux内核会相应地创建一个eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关。eventpoll 结构体如下所示:

struct eventpoll{  
    ....  
   //红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件  
   struct rb_root  rbr;  

   //双链表中则存放着发生的事件,其中包含了对应的描述符
   struct list_head rdlist;  
   ....  
};  

每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是log(n),其中n为树的高度)。

而所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫 ep_poll_callback,它会将发生的事件添加到 rdlist 双链表中。内核不像 select/poll 样需要轮训所有的描述符来检测有哪些描述符是就绪的,基于事件的回调使得 epoll 性能更高。

对于每一个事件,都对应一个 epitem 结构体,如下所示:

struct epitem{  
    struct rb_node  rbn;//红黑树节点  
    struct list_head    rdllink;//双向链表节点  
    struct epoll_filefd  ffd;  //事件句柄信息  
    struct eventpoll *ep;    //指向其所属的eventpoll对象  
    struct epoll_event event; //期待发生的事件类型  
}  

epoll_wait() 中检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双链表中是否有 epitem 元素即可,这就是基于事件回调的好处。如果rdlist不为空,则把其中的 epitem 复制到用户内存空间,同时将事件数量返回给用户。

这里写图片描述
epoll数据结构示意图

从上面的讲解可知:通过红黑树和双链表数据结构,并结合回调机制,造就了 epoll 的高效。

讲解完了epoll的机理,我们便能很容易掌握epoll的用法了。一句话描述就是:三步曲。

  • epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。
  • epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。
  • epoll_wait()系统调用。通过此调用收集在epoll监控中已经发生的事件。

最后,附上一个 epoll 编程的框架(来自网络):



   for( ; ; )
      {
          nfds = epoll_wait(epfd,events,20,500);
          for(i=0;i<nfds;++i)
          {
              if(events[i].data.fd==listenfd) //有新的连接
              {
                  connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接
                  ev.data.fd=connfd;
                 ev.events=EPOLLIN|EPOLLET;
                 epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中
             }
             else if( events[i].events&EPOLLIN ) //接收到数据,读socket
             {
                 n = read(sockfd, line, MAXLINE)) < 0    //读
                 ev.data.ptr = md;     //md为自定义类型,添加数据
                 ev.events=EPOLLOUT|EPOLLET;
                 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓
             }
             else if(events[i].events&EPOLLOUT) //有数据待发送,写socket
             {
                 struct myepoll_data* md = (myepoll_data*)events[i].data.ptr;    //取数据
                 sockfd = md->fd;
                 send( sockfd, md->ptr, strlen((char*)md->ptr), 0 );        //发送数据
                 ev.data.fd=sockfd;
                 ev.events=EPOLLIN|EPOLLET;
                 epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
             }
             else
             {
                 //其他的处理
             }
         }
     }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值