C++服务器设计(三):多线程模型设计

时间:2023-03-09 06:51:38
C++服务器设计(三):多线程模型设计

多线程探讨

  如今大多数CPU都具有多个核心,为了最大程度的发挥多核处理器的效能,提高服务器的并发性,保证系统对于多线程的支持是十分必要的。我们在之前的设计都是基于单线程而言,在此章我们将对系统进行改进,在进一步提升系统性能的同时保证系统对于多线程的支持。

  首先考虑这么几个问题,我们之前已经选定了基于I/O复用的Reactor模式,那么在多线程环境下我们该如何处理这些I/O?多线程同时处理同一个套接字描述符安全吗?Reactor模式支持多线程吗?

  根据查阅文档可知,针对文件描述符的常见系统调用如read、write是线程安全的,我们不用担心多个线程同时操作文件描述符会导致进程崩溃发生。同时根据UNPv1描述,在两个线程中分别对同一个套接字进行read操作和write操作是线程安全的,因为TCP套接字是双向I/O。

  但是我们依然要考虑如下的集中情况:

  • l  两个线程同时读同一个套接字,此时两个线程各自收到同一条消息的一部分数据,如何把这两部分数据合并成一条完整的消息?
  • l  两个线程同时写同一个套接字,此时每个线程都只发送出去了半条消息,接收方将如何处理接收到的数据?

  如果我们给每个套接字配一把锁,让每次只能有一个线程获得锁来读或者写这个套接字,这能够解决以上的问题。但是在Reactor模式中,我们应该尽量避免阻塞线程的操作。如果此时某个线程中的事件处理器竞争锁失败被阻塞,将会导致该线程之后的其他事件处理也全部被阻塞。

  因此,我们认为虽然描述符常见系统调用是线程安全的,但是由于将一个描述符置于多线程环境中将会使整个业务逻辑复杂化,虽然一定程度上我们可以通过应用层I/O缓冲加锁机制解决,但是这依旧会导致线程阻塞现象和服务器性能下降,这是得不偿失的。因此我们认为在多线程环境下我们依然要确保每个文件描述符只能有一个线程进行操作。这样既可解决消息收发的顺序问题,同时也避免了各种锁竞争现象。

  在以上符合以上原则的情况下,我们将每个连接套接字的读写操作依旧注册在单一Reactor反应器中。同时我们在之前章节描述过,每个Reactor模式都包含一个线程大循环,因此每个Reactor反应器都应该是单线程的,可以支持注册多个连接套接字。但是如果将所有连接套接字都注册在一个线程中,我们的系统就退化为了单线程服务器了。因此我们应该将每一个新连接平均分配到不同的反应器事件循环中,让多个线程平均注册不同的连接事件,让每个线程处理该线程内的所有反应器事件。

One loop per thread模型介绍

  根据之前分析,我们服务器系统的多线程模型已经大致清晰,即采用non-blocking IO + one loop per thread模式。在该模式下,创建多个线程,并且每个线程都创建Reactor反应器,每个反应器又存在一个事件循环(event loop),用于等待注册事件和处理事件的读写。当我们需要让哪个线程干活,我们就把某个新的连接套接字注册到该线程所在的反应器中即可。

  在这种模式中,虽然我们要注意每个套接字只能注册到一个线程反应器中,不能跨反应器使用,但是这种可以分配套接字所在线程的方式依旧能够给我们的系统带来很大的负载弹性。比如对于实时性要求较高的连接可以单独占用一个线程;处理数据量大的连接也可以独占一个线程,并把某些数据处理任务分摊到另外几个计算线程中;而某些相对次要的辅助性连接可以多个共享一个线程,只要保证每个连接的处理器无阻塞,依旧能够保证事件处理延迟并不会太高。

  我们可以将这种模式的优点总结如下:

  • l  线程数量在程序启动时设置,数量确定,并通过线程池管理,不会频繁创建与销毁线程的开销。
  • l  可以很方便的在线程间调节负载。
  • l  对于同一个TCP连接而言,整个连接期间所在线程固定,不必考虑事件并发的可能。

线程间任务队列模型设计

  线程间任务队列模型是一种多线程处理的形式。处理过程中将需要在某个线程中运行的任务注册到该线程的任务队列中,当该线程检测到任务队列中存在任务时,将会取出任务并执行。当任务队列中的任务全部都执行完毕后,该线程将会被阻塞,直到有新的任务被注册导致线程被唤醒。

  首先我们不考虑Reactor模式,设计一个符合以上需求的模型。在这个模型中线程的关键数据结构是任务队列。

  任务队列类似于缓冲无限大的多生产者多消费者模型。缓冲通过条件变量进行多线程保护。生产者和消费者均在不同线程中,生产者通过post操作向缓冲尾部添加任务,消费者通过take操作从缓冲头部获取任务。可能存在多个生产者的情况,因此如果有某个生产者期望添加任务,需要获取同步锁后才能进行添加操作。而消费者不但需要获取同步锁,而且还要检查当前任务队列是否存在可用任务,如果存在则取出,如果不存在则通过条件变量被阻塞,直到存在某个生产者添加了新的任务并执行唤醒操作。

  任务队列的缓冲部分应该支持从头部读取,从尾部写入的队列功能。同时最好能够支持缓冲动态增长,使其在生产者的角度看来缓冲应该类似无限大,以保证不会出现生产者写入过多任务导致操作被阻塞的情况。在STL库中,deque结构作为动态增长分段连续的双向容器,可以很好的满足以上需求,因此我们采用STL库的std::deque作为缓冲实现。

  同时缓冲的任务数据部分,类似于之前章节我们分析的回调函数。它是对象,能够以数据结构的形式被写入缓冲中,当从缓冲中读取出来后,它又能够以类似于函数的形式被调用,最好还能带有自身参数的管理。boost库中的function<void>函数对象实现了这一功能,它可以通过普通函数赋值,也可以通过同为boost库中的bind函数绑定带参数函数或某个成员函数。它能够被当做一个对象供缓冲容器保存,同时也能够作为回调函数被执行。

  通过以上研究设计,我们的任务队列是一个以条件变量进行多线程保护的缓冲,该缓冲的底层数据结构实现为std::deque<boost::function<void()> >。

C++服务器设计(三):多线程模型设计

图3-12 线程间任务队列模型

  最终的线程间任务队列设计实现如图所示。线程主体是一个任务循环,它会反复从任务队列中take可用任务。如果当前任务队列没有任务,take操作将使线程阻塞,直到其他线程中添加了新的可用任务到该任务队列,才会将该线程唤醒并获取任务。当线程获取到任务后,将会在本线程中执行任务回调。当任务执行结束后,线程将重新进入循环,再次期待从任务队列中take到可用任务。

  通过该线程间任务队列模型,我们可以将期望的任务操作从某个线程转移到另一个线程中执行。

线程模型与Reactor模式结合

  之前的线程间任务队列模型设计中,我们并没有考虑到Reactor模式的特性,更没有联系到服务器系统的具体需求场景中。因此我们仍需要对该模型进行改造,使之融入到我们的整个服务器系统中。

  Reactor模式下的系统原型类似于图3-6,其主体是事件循环下的事件分离器监听事件产生,并回调具体事件的handler进行处理。我们给每个反应器添加一个任务队列结构,用于缓冲其他线程向该线程注册的任务。同时我们需要知道其他线程是何时向该Reactor反应器添加了任务。因为之前的Reactor反应器并不能监听任务队列的数量,并且Reactor可能会被阻塞在epoll事件监听中,如果长期没有事件被监听,整个反应器线程将会被长期阻塞,即使此时有其他线程向该反应器添加了任务,也无法得到及时执行。

  我们通过给每个反应器额外创建一个管道,并将该管道的描述符可读事件注册到该反应器中。该描述符同样向其他线程暴露,当其他线程通过该反应器的任务队列向其添加了新任务后,再获取该反应器的管道描述符,并执行写操作。此时我们只需写入随便一字节数据,目的是唤醒可能处于事件监听而阻塞的该反应器,通知它任务队列存在可用任务,需要执行处理。

C++服务器设计(三):多线程模型设计

图3-13 支持任务队列的Reactor反应器模型

  我们设计的支持任务队列的Reactor反应器如图3-13所示。在反应器初始化阶段创建一个管道,并将该管道描述符注册到反应器中,以便其他线程能够唤醒该反应器。同时在反应器处理完所有激活事件的handler后,会检查自身的任务队列是否为空。在这里不同于之前的线程模型设计,如果任务队列为空,表明当前没有任务可执行,反应器不能够被阻塞于此,而是直接跳过进入下一轮循环;如果任务队列非空,就把任务队列中的所有任务全部读取出来,并依次回调执行,执行完后进入下一轮循环。因为反应器的要求就是尽可能的非阻塞,它的核心是事件处理,而我们的任务队列类似于承属于管道描述符的特殊事件处理。因此对于该事件而言,它不同于线程模型,是有任务则处理,无任务则跳过。

  而在其他线程中,如果想对某个反应器添加任务,只需先获取该反应器的任务队列,向任务队列添加线程,再获取该反应器的管道描述符,通过写入任意数据将该反应器唤醒即可。

服务器系统中多线程的运用

  在服务器系统中,我们使用支持多线程的Reactor模式,并综合新连接创建和线程分配的业务场景,确定了最后的服务器底层模型。

C++服务器设计(三):多线程模型设计

图3-14 多线程的Reactor模型

  如图3-14所示,系统中存在一个main Reactor负责监听accept连接。每当有新的连接产生时,反应器回调监听套接字处理器,并在其中创建一个任务,该任务是将这个新连接注册到某个指定的反应器中,并向该反应器发送唤醒事件。

  同时系统通过线程池管理多个工作反应器,工作反应器的数量是可以设置的,可以根据CPU的数目来确定恰当的数量。每当监听Reactor中有新连接产生时,将会通过Round Robin轮询调度从线程池中选出一个工作反应器,作为新任务的发送对象。被选中的这个工作反应器也将会作为该连接的实际管理者,这个连接的所有操作都会在这个工作反应器所在线程中完成。

  通过以上设计,我们的系统不但能够通过多线程充分利用到了多核CPU的性能,又通过固定线程数避免了系统总体处理能力不会随连接数增加而下降。同时由于一个连接完全由一个线程管理,保证了对该连接的读写及事件处理的能够按照顺序执行,简化了多线程下实际业务逻辑的处理过程。