Linux 下高并发服务器的研究与实现
2019-11-03李明陈琳
李明 陈琳
摘要:Linux作为一个稳定、开源、拥有完善的网络功能的操作系统,在涉及网络相关的软件开发时具有得天独厚的优势。在进行网络通信程序的开发时,通常采用 socket 来进行网络同信。在基于 socket编程的基础上,对比了 Linux 系统下三种多路复用 I/O 接口:select、poll、epoll 后。,确定了以 socket、epoll 机制以及线程池为基础来设计与实现一个客户端/服务器(client/server)模型的高并发服务器。基于该模型的基础上,研究epoll 和事件驱动模型(Reactor)的实现原理。
关键词:Linux;epoll;socket;高并发;事件驱动模型
中图分类号:TP3 文献标识码:A
文章编号:1009-3044(2019)23-0259-03
开放科学(资源服务)标识码(OSID):
1 引言
网络上的应用程序通常通过“套接字”向网络发出请求或者应答请求,建立网络通信连接至少需要一对端口号(socket)。Socket 的本质是封装了 TCP/IP 后提供给程序员进行网络开发的接口。而要实现高并发的网络通信服务器,除了掌握 socket 的知识外,还需要了解 I/O 多路复用机制。作为 Linux 下多路复用 I/O的机制,select 模型具有最大的并发数限制,和效率问题,以及内核/用户控件内存拷贝的问题。随后提出的Poll 模型虽然在 select 机制的基础上解决了最大并发数的限制,但依然存在效率问题和内存拷贝的问题。在基于前面二者的基础上,Linux2.6 版本之后推出了 epoll 模型来解决上述问题。
2 Socket 原理及应用
2.1 socket通讯原理
在 Linux 环境下,Socket 是一种用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。作为一种全双工通信的模式,一个文件描述符对应 socket 的2个缓冲区,一个用于读,一个用于写。 在 TCP/IP 协议中,IP 加端口可以唯一确定一个Socket ,而想要建立连接的额两个进程各自有一个 socket 对应,这两个 socket 组成的 socket pair 就可以确定一个唯一的连接。因此可以用 socket 来描述网络中的一对一连接关系。套接字通信原理如图1所示:
2.2 socket 通信流程
利用 socket 进行网络通信的 C/S 模型分为客户端和服务器端。服务器端要做的工作主要为创建 socket、绑定 IP 地址和端口、设置同时最大连接数、监听并接受客户端的连接请求、读取客户端发送的数据、处理请求、回写数据到客户端、完成并关闭这次连接。
客户端需要进行的工作则为创建 socket、建立连接、向服务器端写数据、读取服务器端回写的数据、结束这次连接。图二展示了一个完整的网络通信的过程。
2.3 TCP 连接的建立与释放
在实现高并发的 C/S 模型服务器是,选择了TCP作为通信协议。TCP是一个面向连接的协议,相对于无连接的 UDP协议,TCP通过三次握手建立连接的方式在很大程度上保证连接与传输的可靠性。三次握手的流程如下:
l 客户端向服务器发送一个 SYN J
l 服务器向客户端发响应一个SYN K, 并对SYN J进行确认 ACK J + 1
l 客户端再向服务器端发送一个确认 ACK K + 1
而在某个应用进程完成通信后,就需要释放连接,而 TCP是通过四次握手来释放连接。
l 客户端首先调用close主动关闭连接,TCP 發送一个FIN M
l 服务端接收 FIN M之后,执行被动关闭,对FIN M进行确认。
l 一段时间后,接收到文件结束符的应用进程调用 close 关闭自身的socket,同时发送一个FIN N
l 接收到 FIN N 的客户端TCP 对 FIN N进行确认。
TCP的连接与释放的示意图如图3所示:
3 epoll + 线程池实现高并发服务器
3.1 epoll 反应堆模型
3.1.1 epoll API详解
目前。epoll是Linux 大规模并发网络程序中的热门首选模型。epoll为开发者提供了3个系统调用。它们的定义如下:
#include
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 () 函数来创建一个句柄,并且在系统内核维护了一个红黑树和就绪list链表。所以在每次调用epoll_wait时,不需要传递整个fd列表给内核,epoll_ctl每次只需要进行增量式操作即可。在调用了epoll_create 之后,内核已经准备了一个数据结构用于存放需要监控的 fd,即注册上来的事件了。
通过 epoll_ctl () 函数来注册需要监听的fd以及对应的事件类型。在调用epoll_ctl向句柄上注册百万个fd时,epoll_wait依然能够快速返回并且有效地将触发的事件fd返回给用户,因为在调用epoll_create时,内核除了帮我们在epoll文件系统新建file节点,同时在内核cache创建红黑树用于存储以后由epoll_ctl注册上来的fd外,另外建立了一个list链表用于存储准备就绪的事件。当epoll_wait被调用时,只观察list链表有无数据即可。如果list链表中有数据则返回链表中数据,没有数据则等待timeout超时返回。即使在高并发情况下我们需要监控百万级别的fd时,通常情况下,一次也只返回少量准备就绪的fd而已。所以每次调用epoll_wait时只需要从内核态复制少量就绪的fd到用户空间即可。
最后通过 epoll_wait () 来将list链表上准备就绪的fd复制到用户空间,然后返回给用户。而该list链表是通过给内核中断处理程序注册一个回调函数,当fd中断到达时,就将它放入到list链表中。
因此,通过一颗红黑树、一张准备就绪的fd链表以及少量的内核cache,就解决了高并发下的fd处理问题。
3.1.2 epoll 反应堆模型
epoll除了提供 select/poll 那种I/O事件的水平触发外,还提供了边沿触发,。通过epoll API 、边沿触发、非阻塞 I/O 的方式加上一个自定义的结构体来实现一个反应堆模式进一步提高程序的高并发能力和效率。
Epoll 默认方式为水平触发即有事件发生时且缓冲区中有数据可读或有空间可写时,则会持续触发直到数据处理完毕,而边沿触发则是当状态发生改变时则触发。通过设置边沿触发在处理大量数据时可以只读取数据头,在服务器解析头部后决定继续读取数据还是丢弃处理以此节省更多服务器开销。而为了避免边沿触发导致死锁的形成,需要配合非阻塞I/O的方式来进行处理。
为了实现epoll的反应堆模型,需要自定义一个结构体 my_events 来保存相关的元素。该结构体最少应该包含文件描述符、事件类型、一个泛型指针、一个回调函数。同时声明一个该结构体的数组用于保存连接上来的客户端。
struct my_events{
int fd;
int events;
void *arg;
void (*call_back)(int fd, int events, void* arg); /
int status;
char buf[BUFLEN];
int len;
long last_active;
};
struct my_events g_events[MAX_EVENTS+1];
通过该结构体中的泛型指针指向这个结构体本身可以使得每个事件拥有自己的回调函数。从而使得主程序只负责监听就绪的事件,而将数据的处理放到回调函数中进行。epoll Reactor模式的大致流程如下:
l 程序设置边沿触发和fd的非阻塞I/O
l 利用 epoll_create 来创建一个句柄和内部实现的红黑树
l 初始化创建并绑定监听 socket,并返回一个文件描述符 listen_fd,并添加到红黑树上
l 监听可读事件(ET) ? 数据到来 ? 触发事件 ? epoll_wait()返回 ? 读取完数据(可读事件回调函数内) ? 将该节点从红黑树上摘下(可读事件回调函数内) ? 设置可写事件和对应可写回调函数(可读事件回调函数内) ? 挂上树(可读事件回调函数内) ? 处理数据(可读事件回调函数内)
l 监听可写事件(ET) ? 对方可读 ? 触发事件 ? epoll_wait()返回 ? 写完数据(可写事件回调函数内) ? 将该节点从红黑树上摘下(可写事件回调函数内) ? 设置可读事件和对应可读回调函数(可写读事件回调函数内) ? 挂上树(可写事件回调函数内) ? 处理收尾工作(可写事件回调函数内)
l 程序循环执行
3.2 线程池
在目前的大多数网络服务器中,单位时间内必须处理数目巨大的连接请求,但处理时间却相对较短。传统的每接收一个请求就创建一个线程的模式在处理大量的短连接,任务执行时间短的连接请求时将会使服务器长时间处于创建线程和销毁线程的状态中,极大的浪费CPU资源。线程池是一种线程使用模式,线程过多会带来调度的开销进而影响缓存局部性和整体性能,而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价,它保证了内核的充分利用,防止了过度调用。
构建一个线程池的框架一般具有如下几个部分。一个自定义结构体threadpool_t用于描述线程池的相关信息,包括有用于线程间同步的互斥锁和信号量、保存工作线程线程号的数组、一个管理者线程、管理任务的任务队列、以及记录线程池内最小线程数,工作线程数,最大线程数等的变量。然后定义相关的函数API,其中主要的函数定义如下所示:
1. threadpool_t *threadpool_create(): 用于创建和初始化一个线程池
2. int threadpool_add():用于向线程池的任务队列添中加一个任务
3. void *threadpool_thread():線程池中的各工作线程
4. void *adjust_thread () : 管理者线程,负责线程池的维护
在程序开始时,预创建一个线程池和最小数量的线程放入空闲队列中等待唤醒,在任务到来之前,线程处于阻塞状态不会占用CPU资源,在任务到达以后,在线程池中唤醒一个线程来接收此任务并处理。当任务队列中任务较多而当前工作线程数量不够支撑时,线程池会通过管理者线程向线程池中添加一定数量的新线程,而当空闲线程数过多而任务队里任务较少时,管理者线程也会从线程中销毁一部分线程,回收系统资源,动态的管理线程池。通过这种预处理技术,线程创建和销毁带来的开销则分摊到各个具体的任务上,执行次数越多,每个任务分摊的线程本身开销越小,达到了提高系统效率,节约系统资源的目的。
在实际的应用当中,线程池并不适用于所有场景。它致力于减少线程本身的开销对应用所产生的影响。但是对于一些任务执行时间较长的服务例如FTP和TELNET,相较于文件传输的时间,线程创建和销毁的开销可以忽略不计,此时使用线程池并不能带俩效率上的明显提高。总结起来。线程池使用于单位时间内处理任务频繁且任务处理事件短、对实时性要求高、以及高突发性的事件。
4 总结
通过使用epoll多路I/O复用,设置边沿触发和非阻塞的方式,基于事件驱动模式实现的服务器已经能够实现高并发的需求,同时将接受到连接请求和具体的任务通过线程池的方式来处理,进一步提高了并发服务器的处理效率和并发能力,同时对与突发性的访问量突增的情况也能良好的适应与处理。
但是在设计与实现不同的高并发服务器时,epoll和线程池并不适用于所有场景。其他的多路I/O复用 sleclt/poll加上传统的“及时创建,及时销毁“多线程策略也有适合的应用场景。因此需要根据实际情况和不同场景选择不同的设计模式,实现最符合需求的并发服务器。
参考文献:
[1] Andrew S Tanenbaum, 计算机网络[M]. 熊桂喜等译, 北京:清华大学出版社,1998.
[2] 陈硕,Linux 多线程服务端编程[M]. 北京:电子工业出版社,2013.
[3] 唐富强, 于鸿洋, 张萍. Linux下通用线程池的改进与实现[J].计算机工程与应用, 2012, 48(28): 77-78.
[4] 邱杰,朱晓姝,孙小雁. 基于Epoll模型的消息推送研究与实现[J]. 合肥工业大学学报, 2016, 39(4): 476-477.
[5] 余光远. 基于Epoll的消息推送系统的设计与实现[D]. 武汉:华中科技大学, 2011.
[6] 张超,潘旭东 Linux下基于EPOLL机制的海量网络信息处理模型[J].强激光与粒子束, 2013,25(Z1):46-50.
[7] 杨开杰,刘秋菊,徐汀荣.线程池的多线程并发控制技术研究[J].计算机应用与软件,2010,27(1):169-170.
[8] 刘新强,曾兵义.用线程池解决服务器并发请求的方案设计[J].现代电子技术,2011,34(15):142-143.
【通联编辑:梁书】