Linux网络编程(三)高性能网络编程

服务器模型

C/S模型

即传统的Client/Server模型,客户端通过访问服务器来获取所需的资源。

串行效率非常低下,服务器需要同时保持多个客户端的监听和提供请求。

比如通过select系统调用的C/S模型工作流:

P2P模型

C/S模型的缺点在于,如果访问量非常大,服务器会受到比较高的压力,并且如果主服务器宕机会影响整个服务,当然通过分布式可以解决这些问题,另一种解决的模型是Peer to Peer点对点模型,这种网络中每个主机的地位是等同的,每台机器在消耗服务的同时也在为他人提供服务,既是客户端也是服务端。

当然需要有一台发现服务器,用于资源的发现,有些也可以提供内容,从而使得各个客户能够查找自己所需资源的位置。

服务器编程框架

模块 单服务器 服务器集群
I/O处理单元 处理客户连接,读写网络数据 接入服务器,实现负载均衡
逻辑单元 业务进程 逻辑服务器
网络存储单元 本地数据库、文件、缓存 数据库服务器
请求队列 单元之间的通信 服务器之间的通信

对于I/O来说,非阻塞I/O和I/O多路复用技术是高性能网络编程的常见技术,下面聊一聊非常重要的I/O模型。

阻塞I/O与非阻塞I/O

I/O分为阻塞I/O和非阻塞I/O,socket在创建的时候默认是阻塞的,可以通过设置SOCK_NONBLOCK或者系统调用F_SETFL来将其设置为非阻塞的。

阻塞I/O

阻塞I/O没有完成时会被系统挂起,直到等待的事件完成。可能会被阻塞的系统调用包括acceptsendrecvconnect等,也就是在发送请求后没有收到返回,那么请求就会挂起,直到收到确认报文后才会唤醒该调用。

非阻塞I/O

非阻塞I/O则总是立即返回,而不管事件是否已经发生。如果没有立即发生就返回error,需要通过errno来区别是没接收到立即的返回还是出错。如果只是单纯的使用非阻塞I/O是没有意义的,直接得到结果但是无法真正处理事件,所以非阻塞I/O需要配合I/O通知机制一起使用,等事件就绪了再处理,I/O通知机制包括I/O复用和SIGIO信号。在后面会详细介绍。

同步I/O与异步I/O

同步I/O

同步I/O指的是I/O的读写操作都在I/O事件发生之后,应用程序完成。同步I/O需要由用户自己来执行I/O操作。向应用程序通知的是I/O就绪事件。

前面提到的阻塞I/O、I/O复用、SIGIO信号都是同步I/O

异步I/O

异步I/O用户可以直接执行I/O读写,读写操作总是立即返回,而不管I/O是否是阻塞的。异步I/O直接由内核执行I/O操作。向应用程序通知的是I/O完成事件。

事件处理模式

高效的事件处理模式包括Reactor和Proactor,其中同步I/O主要实现Reactor,异步I/O主要实现Proactor。

Reactor

主线程只负责监听I/O处理单元上是否有事件发生,有的话就通知逻辑单元上的工作线程,剩下的都交给工作线程来完成。

工作流程如下图所示:

同步I/O实现的Reactor模型流程如下: (I/O复用以epoll为例)

  1. 主线程向epoll事件表中注册socket的读就绪事件
  2. 主线程调用epoll_wait等待socket上有数据可读
  3. 一旦socket上有数据可读,epool_wait就通知主线程,主线程将socket可读事件放入请求队列
  4. 在请求队列上睡眠的某个对应工作线程会唤醒,然后从socket中读取数据并处理客户请求,然后向epoll事件表注册socket的写就绪事件
  5. 主线程调用epoll_wait等待socket上有数据可写
  6. 一旦socket上有数据可写,epool_wait就通知主线程,主线程将socket可写事件放入请求队列
  7. 在请求队列上睡眠的某个对应工作线程会唤醒,然后从socket中写入用户请求的结果

对于一次请求流程来说,是先读取socket上的用户请求的数据,然后写回结果到socket。实际上对于主线程来说read和write也没有本质区别,只需要通过判断事件类型然后放入请求队列唤醒对应的工作线程即可。

Proactor

Proactor模式下,所有的I/O操作都交给主线程和内核来处理,工作线程只负责业务逻辑。主线程epoll_wait只监听socket的连接请求事件,不能用于检测socket的读写,之后的I/O处理都交给内核。

异步I/O实现Proactive的流程:

1、 主线程调用aio_read向内核注册socket的读完成事件,并告诉内核用户读缓冲区的位置和读操作完成时的通知应用程序的方式 2. 主线程继续处理其他逻辑 3. socket数据读入用户缓冲区后,内核向应用程序发送信号,通知数据可用 4. 应用程序根据已经定义好的信号处理函数选择工作线程处理用户请求,工作线程处理完后调用aio_write向内核注册socket写完成事件,并告诉内核用户写缓冲区的位置和写操作完成时的通知应用程序的方式 5. 主线程继续处理其他逻辑 6. 用户缓冲区数据写入socket后,内核向应用程序发送信号,通知数据已发送完 7. 应用程序根据已经定义好的信号处理函数选择一个工作线程做善后处理,如是否关闭socket

并发编程模式

半同步/半异步模式

这里和之前的同步异步I/O完全不同,这里的同步表示程序完全按照代码的顺序执行,异步表示程序执行需要由系统事件来驱动。

同步线程逻辑简单,但是效率低、实时性差 异步线程执行效率高、实时性强,但是逻辑复杂、不适合大量的并发

同时使用同步线程和异步线程,就能既做到有较好的实时性,也能同时处理多个客户端的请求。

同步线程用来处理客户逻辑,异步线程用来处理I/O事件。异步线程监听到客户请求后,就封装为请求对象然后插入到请求队列中,请求队列会通知运行在同步模式的工作线程来处理这个请求。

一个用的多的变体实例是半同步/半反应堆模式,主线程为唯一的异步线程,监听socket上的事件:

领导者/追随者模式

多个工作线程轮流获得事件源集合,进行轮流监听、分发和处理事件。

同时只能有一个领导者线程来复杂监听I/O事件,其他的都是追随者,如果监听到了I/O事件,就先从追随者里选出新的领导者,此时新的领导者继续监听I/O事件,然后旧的领导者开始处理之前监听到的事件。

线程的状态转移:(Processing表示正在处理事件)

高性能编程的一些其他手段

相比于空间,响应时间对于大多数服务来说更重要,池(Pool)就是通过空间换时间。

池是一组资源的集合,服务器启动的时候就初始化好,完成静态资源分配,之后如果需要相关的资源可以直接从池中获取,而不需要重新创建,从而节约系统资源分配的时间。

初始化时无法确定具体需要多少资源,所以就分配足够多的资源。

根据不同的资源类型,池可以分为内存池、进程池、线程池、连接池。

数据复制

高性能服务器需要避免不必要的复制,尤其是用户代码和内核之间的复制,比如ftp服务器不需要将文件读入到应用程序缓冲区然后调用send函数发送,而是直接可以通过调用sendfile从内核直接发送给客户端。

I/O复用

I/O复用是最常见的I/O通知机制,指的是应用程序通过I/O复用函数向内核注册一系列事件,内核通过I/O复用函数将就绪的事件返回给应用程序。

I/O复用使得程序可以同时监听多个文件描述符(包括socket、用户IO等),从而提高程序性能,比如:

  1. 同时处理多个socket
  2. 同时处理用户输入和网络连接(比如聊天室)
  3. 同时监听和连接socket(使用最多的场合)
  4. 同时处理TCP和UDP连接
  5. 同时监听多个端口

需要注意的是,I/O复用函数本身是阻塞的,多个文件描述符就绪时也只能串行处理,I/O复用依靠同时监听多个I/O事件来达到高效率。

如果还要实现多个就绪文件描述符的并发处理,就只能采用多进程或多线程并发,这个后面再讲。

Linux中最常见的I/O复用有select、poll和epoll。

select

I/O多路复用指的是一个线程处理多个IO流,也就是常说的selct/epoll机制。

可以把标准输入、套接字都看作I/O的一路,多路复用就是在任何一路有I/O事件的地方通知程序去处理相应的I/O事件。 如果有标准输入,就直接从标准输入中读取数据,如果有套接字数据可以读,就直接读出数据。

select是常见的一个I/O多路复用机制,采用事件轮询机制,最大的缺点就是,支持的文件描述符的最大个数是1024个。

int select(int nfds, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout)

nfds:被监听的文件描述符的总数 readset:读描述符集合 writeset:写描述符集合 exceptset:异常描述符集合 timeout:select函数的超时时间

描述符集合的作用是通知内核哪些数据可以读/写/异常等,理解为一个0/1构成的向量。fd_set结构体是一个整型数组,每个bit标记一个文件描述符。

通过

1
2
3
4
void FD_ZERO(fd_set *fdset);      
void FD_SET(int fd, fd_set *fdset);  
void FD_CLR(int fd, fd_set *fdset);   
int  FD_ISSET(int fd, fd_set *fdset);

这几个宏进行设置,其中FD_ZERO会将向量的所有元素设为0,FD_SET会将对应fd的元素位置设为1, FD_CLR将对应fd的元素位置设为0,FD_ISSET会查找fd是0还是1。0代表不处理,1代表要处理。

select的返回值:成功时返回就绪文件描述符综述,如果超时还没有任何一个就绪就返回0,失败则返回-1和errno。

poll

poll和select类似,也是轮询一定数量的文件描述符,查看是否有就绪状态的。

int poll(struct polld *fds, nfds_t nfds, int timeout)

fds:pollfd结构的数组,包括文件描述符的可读可写异常等事件

相比于select将三种事件描述符集合都分开作为三个参数,poll的pollfd放入了全部的文件描述符和事件,被统一处理,从而使得编程更简洁,用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈就绪的事件。

返回值的含义和select一样,poll的改进比较有限。

epoll(重点)

epoll是Linux特有的I/O复用函数,在2.6的内核版本才引入,是目前Linux最优秀的多路复用机制,也是本篇的重点内容。

epoll相比于select的改进:

  1. 没有1024个线程的限制
  2. select采用轮询的方式,效率低下,epoll使用回调函数,效率更高

epoll使用一组函数来完成任务,将各个文件描述符的事件统一放在一个事件表里,而不是向select或者poll那样每次调用都要重复传入文件描述符或者事件集。

不过epoll需要一个额外的文件描述符来标识内核中的事件表。

首先通过epoll_create来创建这个文件描述符:

1
int epoll_create(int size)

size是事件表的大小,返回的描述符将被用于其他所有epoll调用的第一个参数,从而能够定位这个事件表。

通过epoll_ctl函数修改事件表:

1
epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)

其中第一个参数就是epoll_create返回的事件表的文件描述符,因为需要对于每个文件描述符绑定响应事件。

op有三种: EPOLL_CTL_ADD:往事件表注册fd上的事件 EPOLL_CTL_MOD:修改fd上的注册事件 EPOLL_CTL_DEL:删除fd上的注册事件

fd就是要操作的文件描述符

event是指定的事件

epoll的核心调用接口是epoll_wait

1
int epoll_wait(int epfd,struct epoll_event*events,int maxevents,int timeout);

它在一段时间之内等待一组文件描述符的事件,maxevent指最多监听的事件个数。

如果检测到事件,就将就绪的事件从epfd指定的事件表中复制到events指定的数组中。

和前面采用轮询机制的的select和poll相比,epoll采用回调机制,轮询的复杂度是$O(n)$,回调是$O(1)$,不过如果活动连接较多,epoll_wait的回调函数触发过于频繁,造成性能下降。所以epoll适用于连接数较多但是活动连接较少的情况。

三者的对比

select poll epoll
事件集合 三个参数分别对应可读、可写与异常事件集合 统一处理所有事件类型,传入一个事件集参数即可 内核通过一个事件表管理所有事件,每个事件绑定回调函数
内核实现方式 采用轮询来检测就绪事件,$O(n)$ 采用轮询来检测就绪事件,$O(n)$ 采用回调函数来检测就绪事件,$O(1)$
最大连接数 1024 不限 不限