服务器设计学习实践篇

2022-08-22

服务器后端设计,高并发,ringbuf,CAS。

最简模型

while(1) { 
  accept(); 
  recv(); 
  do(); 
  send(); 
}

这是服务端程序的最简模型:来一个连接,读取数据(请求),干活(处理),返回数据(应答)。同一时间只为一个客户端服务。

事件驱动

要考虑支持多个客户端,就有了选择,有了选择就叫设计。

我们有两个选择:

  1. 采用select或poll(epoll)多路复用技术,同时监听多个连接以及listen socket的事件。这个叫事件驱动。
  2. 采用多线程(进程)技术,主线程accept一个连接,就创建或者从线程池中分配一个线程,一个线程处理一个连接。这个叫线程驱动。

两者其实没有本质区别,都是中断驱动

第一个增加了编程的难度,我们需要把所有可能的阻塞点,都放入监听队列,避免一个连接阻塞掉所有连接。

第二个增加了线程调度和CPU状态切换开销,却大大降低了编程难度,你不用管是否阻塞,你干不下去的时候,内核会帮你把CPU让给其他线程,一个线程只有一个连接,可以像串一串糖葫芦那样写代码。

coroutine

线程驱动是对事件驱动的一种封装,降低了编程难度,内核帮你干了!不过你可能不满意。

如果追求最大性能,就需要更大的技术难度,自己做内核帮你做的事情。

这个方向做到极致就是用户态线程,还是按照一个连接一个线程的串行模式编写代码,只不过这些线程不由内核调度,开销很低。这些线程叫做协程(coroutine)或者说纤程,或者其他古怪的名号。总之就是一样,对事件驱动的封装,对低效内核调度的替代。为什么比内核调度快?因为一直工作在用户态,没有CPU状态的切换。

Swapcontext

swapcontext 可以帮助我们完成用户态的上下文切换;swapcontext 特别像内核的schedule(switch_to)函数。你可以man它。

完美的协程库需要处理所有可能导致阻塞的系统调用,或者preload动态库,或者符号重定义,或者让用户使用封装函数。

无知的小白:写下如下代码

Coroutine.run(func1)
Coroutine.run(func2)

func1 使用read从网络连接中读取数据,并打印;网络上尚无数据到来。

func2 从内存中读取数据,并打印

结果func2瞬间完成数据打印,并不被func1阻塞。

5s后,网络来了数据,read返回,func1也打印出数据。

协程库做什么?

协程库有一个主循环(上下文),主循环内扫描就绪协程列表,按照策略比如简单的FIFO,选择一个执行。

上述例子中,先选择了func1,并swapcontext到func1的入口;func1调用了read,read被协程库重写了,fd被设置为非阻塞,并把它的读事件放入epoll的监听队列,设置协程为等待状态,从协程列表删除,然后swapcontext进入主循环,主循环继续扫描协程列表,发现了func2,同样swapcontext到func2,读取内存,打印数据,没有协程库重定义的函数调用,func2完毕后,返回主循环,继续扫描协程列表;这时队列中没有就绪协程了,进入epoll_wait等待,5s后,网络有数据,epoll_wait返回,根据事件信息,func1被重新设置为就绪,进入协程列表,得以继续执行。

相比内核态的线程,协程轻如鸿毛,几万甚至几十万的不是事儿。

golang

沿着这个思路继续走下去,就是一个语言了,比如像go语言这种,提供了goroutine,也是用户态轻量级线程。

golang太适合后端服务器程序开发了,简洁明快,活波可爱,找小三就找这种。

程序员怎么可以没有小三,抱着java和C++过一辈子?

——开玩笑了,工具而已,语言多学几门没有坏处,可以帮助我们建立更统一和深刻的技术理解。

多核

多核多CPU的情况下,世界有点不同了,任务可以并行了。

——并发是多少事件同时发生,并行是多少事件同时执行。在任务这个层次,单CPU只有并发问题,没有并行问题。(流水线导致的指令并发对程序员不可见)

你有10个CPU,10个线程可以同时跑互不干扰;如果你只有10个连接的并发,那么其用线程驱动也很美妙和极速。

真的吗?怎么可能!这世界上最可恶的就是多核,它把我们带入并行的深渊;但并行度并不足以应对我们面对的并发容量,动辄上万甚至更高的高并发场景下,你还是要事件驱动,同时还需要充分利用多核,处理好并行。

多线程(进程)事件驱动

每个核上都运行一个线程,每一个线程都有一个事件驱动循环,这叫做多线程(进程)事件驱动。我们可以多个线程同时accept同一个fd。

惊群

如今的内核早就没那么傻了,accept不会惊群,内核使用exclusive放入等待队列,一次只唤醒一个线程,并且是按照FIFO顺序来的。也就说你有多个线程同时accept,内核会采用轮转的方式均匀的分配新连接。

不过你用select或者poll就不行了。select和poll没有使用exclusive方式进入等待队列,唤醒的时候,同时唤醒,但只有一个可以accept成功——典型的惊了群,不过这才符合自然现象,地震了谁都知道,第一个跑出去的只有一个。

epoll所有的等待都是exclusive方式(eventfd可能会有麻烦),但必须所有的线程(进程)使用同一个epoll实例(由epoll_create创建)。

NUMA

如果并发量非常大,accept队列经常都是满的,这时候不会等待,并发执行accept的线程非常多,但accept首先申请一个锁:lock_sock(sk); 这个锁是mutex,高并发的场景可能导致饥饿问题,尤其是在NUMA场景下,CPU访问内存的速度是不同的,有些线程可能连续几次都抢不到这个锁,accept就不均衡了。我曾经写过一简单的测试程序测试两个线程,绑定到同一个物理CPU上的两个核,accept到连接的比例是1:1,但如果是绑定到两个不同物理CPU就变成4:3了。如果竞争更加激烈比例可能会更不均衡。

SO_REUSEPORT

针对accept多线程负载不均衡的问题,google的大神们,提交了一个补丁http://lwn.net/Articles/542629/。

新增了一个socket选项——SO_REUSEPORT ,有了这个选项后,多个线程同时accept一个listen fd时,内核会根据新连接的ip地址和端口的hash值来选择线程。在客户端大量和hash算法均匀的情况下,负载会均匀。 不过我觉得,只需要让lock_sock用排队锁,就可以解决不均衡的问题了。

免锁算法

多核并行时代,锁是最大的敌人。大家开始关注和研究免锁算法,大多数免锁算法的设计和实现非常复杂,很难用于项目实践。

单消费者单生产者的环形队列最成熟最有效的一种,很多其他的基于cas+busywait的免锁队列,本质就是自旋锁而已,却远远不如直接使用封装好的自旋锁简洁,只是有助于炫技罢了。

多核时代高性能的心法是不要想什么免锁算法,而是想办法减少或消除共享,让线程独立而自由的飞翔。

线程独立

有些场景,连接之间共享资源很少,每一个线程独立accept独立事件循环,可以很happy,天然的免锁。

有些场景,就很悲催,比如一个用户,一个IP,但是可以有多个连接,而业务逻辑又是以用户作为基本处理单元,而不是一个连接。这个场景,靠内核给你分配连接,就无法保证同一个用户的连接只到一个线程内。

你需要自己分配。如果google那个补丁可以选择参与hash的元素,岂不是很美妙,比如只按客户端地址hash,同一个用户自然只会跑到同一个线程了。

——还好,内核是开源的,你或许可以自己修改它。

1:N 和 M:N

如果不修改内核,就需要一个分配线程,只做accept和连接分配这一件事情,其他多个工作线程负责处理。分配策略就按用户IP hash分配。

这是一个典型的1:N场景,有什么特殊意义呢,这意味着分配线程把新连接(或其他通信内容)发给工作线程可以使用环形队列,每个工作线程一个队列,都是单生产者单消费1:1的队列,可以完全免锁。

如果并发连接太多,一个分配线程搞不定,就很悲催,M:N 无法简单的化为1:1的通信模型。

内存池

在线程独立,减少共享这个设计思想前提下,内存池应该是线程私有的;每一个线程的内存池都是独立的,没有并发请求,这样的内存池会非常简单和高效。

每个内存池管理的内存块,设计成固定大小,相比支持各种大小的通用的系统malloc,实现大大简化,性能会大幅度提高。

预先申请好内存,按照分配大小切分成内存块;只需要一个简单的队列(链表)维护空闲的内存块即可;申请释放就是入队和出队,当然这个队列应该是后进先出(LIFO)的,这样可以增加cache命中。

可以在内存块前端或尾部埋入cookie,做一些可靠性设计,异常时及时coredump,C或C++最怕内存越界,越早挂掉,越接近犯罪现场越好分析。