基于epoll与线程池实现的C++网络库,其中服务端采用线程间共享互斥锁避免惊群效应。详解架构设计、异步日志、支持非阻塞IO的缓冲。阅读时长约20min。

从Nginx说起

Nginx是使用多进程与IO多路复用实现的高性能C++服务端,但是Nginx为什么要使用多进程,而不是多线程呢?

子线程与子进程的主要区别在于子进程有独立的PID与地址空间,独立PID在并发编程时使得信号的处理更加方便,因为信号根据种类不同,作用于线程组时的行为也有很大的不一样。但由于父子进程各自的地址空间是独立的,所以nginx在避免惊群效应时使用了进程锁,分析源代码底层有两种实现:

打开网易新闻 查看精彩图片

①基于CAS(Compare and Swap)与共享内存,其中CAS需要CPU硬件支持。②使用fcntl系统调用实现记录锁。

进程锁的实现相对麻烦,以前甚至碰到过fcntl系统调用对记录锁的实现有BUG。

作者尝试使用posix线程库实现线程池以替换多进程,同样可以实现高性能,框架命名为kingpin。

架构设计

对于一个库而言,设计者最开始就要想清楚的是,想要解决的问题、针对的业务场景、怎样设计API?

Kingpin想要解决的问题,就是使开发者在构建高性能C++网络服务时可以专注于业务逻辑,不需要关注并发本身的实现。这里的网络服务泛指服务端、压测客户端、爬虫等。

打开网易新闻 查看精彩图片

  1. 使用kingpin构建后端服务需要实现的API如下:

Server:

onConnect(int conn). 连接建立时回调操作。由于accept4系统调用时默认将已连接套接字设置为O_NONBLOCK模式,所以后续该套接字都是非阻塞的。

onReadable(int conn, uint32_t events). 第二个参数为向epoll监听并且已发生的事件。这里提供这个参数的意义是考虑到使用者可能需要对epoll注册EPOLLRDHUP监听来确定套接字是否已关闭,套接字可写API中该参数意义相同。当然如果不使用这个参数,在read/write系统调用时对返回值及errno进行判断也是可以的,毕竟我们肯定是不能排除该函数执行时对端关闭该套接字的可能性的。

onWritable(int conn, uint32_t events).设计这个API的原因是考虑到套接字缓冲区有可能会被写满,虽然这在一般的web服务器里很少见,但是在大文件传输时却会成为瓶颈。在kingpin的examples里面有这样的实现。

Client:

1、onInit(). 初始化已连接套接字,考虑到爬虫或者压测客户端等业务实现差别较大,框架中并未使用互斥锁保护该函数调用的线程安全性。使用者可以在线程共享数据中自定义连接池、url池等,并采用互斥锁保护共享数据。

2、onReadable(int conn, uint32_t events). 处理套接字可读。onWritable(int conn, uint32_t events). 处理套接字可写。epoll+线程池网络服务模型的架构设计:

打开网易新闻 查看精彩图片

Server:

1、线程池争抢互斥锁,抢占失败不会导致线程阻塞、抢占成功的线程对监听套接字注册读事件。

2、线程调用epoll_wait等待IO事件的发生。线程处理已连接套接字的IO事件。

3、如果监听套接字可读,则使用accept系统调用获取已连接套接字。

4、对监听套接字移除读事件。释放互斥锁。处理连接时回调。

Client:

1、线程初始化已连接套接字(可能放入线程间共享的连接池)。线程对已连接套接字注册感兴趣的事件(通常为读事件)。

2、线程调用epoll_wait等待IO事件的发生。线程处理IO事件。异步日志

打开网易新闻 查看精彩图片

Logger采用后台线程实现,每次启动网络服务时都会有两个后台线程。

比如现在有两个线程同时执行下面的语句:

得到的输出可能是这样的:

打开网易新闻 查看精彩图片

也可能是这样的:

打开网易新闻 查看精彩图片

这里实现的Logger采用行缓冲,内部实现采用递归锁与条件变量。

下面是quick start里面的十几行代码写的一个SimpleServer的线程组情况:

打开网易新闻 查看精彩图片

可以看到2、3就是等待在条件变量处的后台线程。

代码如下:

打开网易新闻 查看精彩图片

还有两个更有趣的demo:

1、大文件下载,本地测试传输100MB文件md5sum校验正确。在这个demo中可以看到写缓冲区已满导致套接字写阻塞

2、一个中国象棋游戏,实现了服务端/客户端/压测客户端,本地1K并发量压测稳定。运行时截图如下:

打开网易新闻 查看精彩图片