第3章 多线程服务器的常用场合与常用编程模型
单线程服务器的常用编程模型
non-blocking IO + IO multiplexing即常用的Reactor模式。
Reactor和Proactor
作者:wuxinliulei
链接:https://www.zhihu.com/question/26943938/answer/68773398
1、标准定义两种I/O多路复用模式:Reactor和Proactor
一般地,I/O多路复用机制都依赖于一个事件多路分离器(Event Demultiplexer)。分离器对象可将来自事件源的I/O事件分离出来,并分发到对应的read/write事件处理器(Event Handler)。开发人员预先注册需要处理的事件及其事件处理器(或回调函数);事件分离器负责将请求事件传递给事件处理器。
两个与事件分离器有关的模式是Reactor和Proactor。Reactor模式采用同步IO,而Proactor采用异步IO。
在Reactor中,事件分离器负责等待文件描述符或socket为读写操作准备就绪,然后将就绪事件传递给对应的处理器,最后由处理器负责完成实际的读写工作。
而在Proactor模式中,处理器–或者兼任处理器的事件分离器,只负责发起异步读写操作。IO操作本身由操作系统来完成。传递给操作系统的参数需要包括用户定义的数据缓冲区地址和数据大小,操作系统才能从中得到写出操作所需数据,或写入从socket读到的数据。事件分离器捕获IO操作完成事件,然后将事件传递给对应处理器。比如,在windows上,处理器发起一个异步IO操作,再由事件分离器等待IOCompletion事件。典型的异步模式实现,都建立在操作系统支持异步API的基础之上,我们将这种实现称为“系统级”异步或“真”异步,因为应用程序完全依赖操作系统执行真正的IO工作。
举个例子,将有助于理解Reactor与Proactor二者的差异,以读操作为例(类操作类似)。在Reactor中实现读:
1 注册读就绪事件和相应的事件处理器- 事件分离器等待事件
2 事件到来,激活分离器,分离器调用事件对应的处理器。
3 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。
在Proactor中实现读:
1 处理器发起异步读操作(注意:操作系统必须支持异步IO)。在这种情况下,处理器无视IO就绪事件,它关注的是完成事件。
2 事件分离器等待操作完成事件
3 在分离器等待过程中,操作系统利用并行的内核线程执行实际的读操作,并将结果数据存入用户自定义缓冲区,最后通知事件分离器读操作完成。
4 事件分离器呼唤处理器。
事件处理器处理用户自定义缓冲区中的数据,然后启动一个新的异步操作,并将控制权返回事件分离器。
可以看出,两个模式的相同点,都是对某个IO事件的事件通知(即告诉某个模块,这个IO操作可以进行或已经完成)。在结构上,两者也有相同点:demultiplexor负责提交IO操作(异步)、查询设备是否可操作(同步),然后当条件满足时,就回调handler;不同点在于,异步情况下(Proactor),当回调handler时,表示IO操作已经完成;同步情况下(Reactor),回调handler时,表示IO设备可以进行某个操作(can read or can write)。
2、通俗理解使用Proactor框架和Reactor框架都可以极大的简化网络应用的开发,但它们的重点却不同。
Reactor框架中用户定义的操作是在实际操作之前调用的。比如你定义了操作是要向一个SOCKET写数据,那么当该SOCKET可以接收数据的时候,你的操作就会被调用;而Proactor框架中用户定义的操作是在实际操作之后调用的。比如你定义了一个操作要显示从SOCKET中读入的数据,那么当读操作完成以后,你的操作才会被调用。
Proactor和Reactor都是并发编程中的设计模式。在我看来,他们都是用于派发/分离IO操作事件的。这里所谓的IO事件也就是诸如read/write的IO操作。"派发/分离"就是将单独的IO事件通知到上层模块。两个模式不同的地方在于,Proactor用于异步IO,而Reactor用于同步IO。
reactor: Linux epoll
proctor: Windows IO completion port.
reactor:能收了你跟俺说一声。
proactor: 你给我收十个字节,收好了跟俺说一声。
多线程服务器的常用编程模型
- 每个请求创建一个线程,使用阻塞式IO。
- 使用线程池+阻塞式IO。
- non-blocking IO + IO multiplexing。
- Leader/Follower等高级模式。
one loop per thread
每个IO线程一个event loop(Reactor)用于处理读写和定时事件。
线程池
使用线程安全的阻塞式队列,实现固定数目线程池,并发地完成一系列任务的例子。
typedef function<void()> Functor;
template<class T>
class BlockingQueue
{
public:
BlockingQueue() : quPtr_(new queue<T>()) {};
void push(const T& t)
{
lock_guard<mutex> lock(mutex_);
quPtr_->push(t);
}
T pop()
{
lock_guard<mutex> lock(mutex_);
assert(!quPtr_->empty());
T t = quPtr_->front();
quPtr_->pop();
return t;
}
bool empty()
{
lock_guard<mutex> lock(mutex_);//比较简单,直接加锁好了
return quPtr_->empty();
}
private:
typedef shared_ptr<queue<T>> QueuePtr;
QueuePtr quPtr_;
mutable mutex mutex_;
};
void workThread(weak_ptr<BlockingQueue<Functor>> pBQ)
{
while (true)//理论上应该是一个开关
{
shared_ptr<BlockingQueue<Functor>> spBQ(pBQ.lock());
if (spBQ && !spBQ->empty())
{
Functor task = spBQ->pop();
task();
}
}
}
void cal(int number)
{
cout << number << endl;
}
int main() {
const int TREAD_NUMBER = 10;//固定数目的线程池
shared_ptr<BlockingQueue<Functor>> spBQ(new BlockingQueue<Functor>());//任务队列
for (int i = 0; i < TREAD_NUMBER; ++i)
{
Functor task = bind(&cal, i);
spBQ->push(task);
}
shared_ptr<thread> pThread[TREAD_NUMBER];
for (int i = 0; i < TREAD_NUMBER; ++i)
{
pThread[i].reset(new thread(workThread, spBQ));
}
system("pause");
return 0;
}
作者推荐的模式
one loop per thread + thread pool:
- event loop(IO loop)用作IO multiplexing,配合non-blocking IO和定时器。
- thread pool用来计算,可以是任务队列或者生产者-消费者队列。
进程间通信只用TCP
进程间通信:匿名管道pipe、有名管道FIFO、POSIX消息队列、共享内存、信号以及socket、mutex、信号量、条件变量、读写锁等等。
使用TCP理由:
- 跨主机,有伸缩性。
- 双工,收发字节流最方便。
- 程序退出时,socket自动回收,不会留下垃圾。
- port独占,防止重复启动。
- 跨语言。
- 出错方便重连。
- 可以广播。
多线程服务器的适用场合
服务端程序的一个基本任务是处理并发连接,两种方式:
- 如果线程很廉价(例如协程),可以创建非常多线程,可以使用阻塞IO,每个线程处理一个连接。
- 线程很宝贵时(原生线程),线程数量和CPU核数相当,一个线程处理多个连接,使用非阻塞IO和IO复用。
以方式2举例,普通服务器有四种任务模式:
- 单进程 每个进程运行 单线程。
- 单进程 每个进程运行 多线程。
- 多进程 每个进程运行 单线程。
- 多进程 每个进程运行 多线程。
分析:
- 模式1无法发挥多核优势。
- 模式4难度大,相比2、3没体现出优势。
- 模式3多进程单线程,主流模式,有两种子模式:
- 3a:运行多个模式1的单进程单线程;
- 3b:主进程+worker进程。
- 模式2的多线程难度更大,性能和3没优势。
本文主要讨论模式2和模式3b。
必须使用单线程的场景
- 程序可能会fork(),只能允许单线程程序fork。
- 限制CPU占用率。
单线程程序的优缺点
单线程程序的一般结构:
while( 1 )
{
if( ( wait_fds = epoll_wait( epoll_fd, evs, cur_fds, -1 ) ) == -1 )
{
printf( "Epoll Wait Error : %d\n", errno );
exit( EXIT_FAILURE );
}
for( i = 0; i < wait_fds; i++ )
{
if( evs[i].data.fd == listen_fd && cur_fds < MAXEPOLL )
{
//......
}
}
}
---------------------
作者:小刀刀
来源:优快云
原文:https://blog.youkuaiyun.com/shanshanpt/article/details/7383400
单线程程序结构简单,一般采用IO复用 + event loop。缺点是非抢占:a任务1ms优先级高,b任务10ms优先级低,但是如果b先来,a只能等b。
多线程程序有性能优势吗?
对于CPU瓶颈和IO瓶颈的程序,多线程没有性能优势,举例:
- 文件服务器,CPU负载较轻,瓶颈在磁盘和网络,单线程程序已经撑满IO,多线程无法提高吞吐量。
- 对于一些时间复杂度较高的程序,CPU被撑满,但是输入数据却很少,这种例子下,使用模式3a的多进程有优势。
总结:IO/CPU任何一方达到瓶颈,多线程都没优势。
适用多线程程序的场景
理想的多线程场景:IO和计算相互重叠,提高响应速度。
多线程程序的必要条件:
- 多核CPU。
- 线程之间有共享数据,否则用模式3b就好。
- 共享数据会被修改,否则用共享内存就好。
- 事件响应有优先级差异,有专门线程处理高优先级的事件,防止优先级反转。
- 延迟和吞吐量同样重要。
- 利用异步操作,比如日志系统。
- 方便扩容。
- 性能可预测。
- 线程责任明确:每个线程逻辑简单,任务单一。
例子 计算机群
举例设计一个服务器机群,8个计算节点,1个控制节点。软件分为3部分:
- 运行在控制节点的master,监控机群状态。
- 运行在每个计算的slave,负责启动/终止job,监控本机资源。
- 用户使用的client,用于提交job。
slave是一个 看门狗 进程,用于启动job线程,是个单线程程序。
master基于模式2单进程多线程:
- 独占8个核心,不能是单线程程序。
- 内存记录整个集群状态,状态是共享的,如果是多进程会带来同步的不方便。
- master监控的事件优先级有区别,不适合单线程。
- master和slave之间有8个TCP连接,使用多个IO线程可以降低延迟。
- 写log需要异步的IO线程。
- master读写数据库,调用第三方库可能有自己的线程。
- master服务client的时候,用几个线程服务client可以降低延迟。
- 给master做一个广播功能,不需要用户主动轮询,把广播做到一个单独线程更容易实现。
graph LR
A((master))-->|TCP| B[slave1&2&3&4&5&6&7&8]
A((master))-->|TCP| B[slave1&2&3&4&5&6&7&8]
A((master))-->|TCP| B[slave1&2&3&4&5&6&7&8]
A((master))-->|TCP| B[slave1&2&3&4&5&6&7&8]
A-->|异步| C[logging]
A-->|服务| D[client1&2]
A-->|服务| D[client1&2]
A-->|pushing| E[广播]
A-->|job调度| F[main]
虽然线程数略多于核数,但是多数线程长期空闲,操作系统完全能够调度过来。
线程分类
作者根据经验,把线程分类如下:
- IO线程:主循环是IO复用,阻塞在select/poll/epoll上,IO线程也处理定时事件。
- 计算线程:主循环是阻塞队列,阻塞在条件变量上,一般位于线程池中。
- 第三方库的线程:logging、数据库等。
服务器程序应该避免频繁启动/终止线程。
作者关于“多线程服务器的适用场合”的答疑讨论
Linux能启动多少线程?
32位linux的内存空间4GB,其中用户空间3GB,每个线程默认栈10MB,可得能够启动约300个线程。
多线程能够提高并发吗?
不能提高“并发连接数”。
对于one thread per connection,最大连接数只能到300(32位系统)。IO复用的单线程可以成千上万。one loop per thread可以发挥多线程优势,并发够大。
多线程能够提高吞吐量吗?
作者认为:计算密集型服务不能。(可是后面的讨论明明是提高了。。。)
举例一个计算服务,单线程耗时0.8s,8核机器用8个线程同时处理8个请求,吞吐量从1.25qps提高到10qps,实际效率会打折。
举例一个压缩程序压缩100个大文将,8线程可以每个线程压缩一个文件,也可以同时并行压缩一个文件,两者吞吐量一样,但是后者更快拿出第一个结果,响应时间更快。
如果适用thread per request,如果请求超过阈值,引起线程切换的开销变大,吞吐量可能会下降。使用线程池可以实现较大连接数并保持吞吐量,线程池要满足阻抗匹配原则。
多线程可以降低响应时间吗?
可以,再突发情况下尤其明显。
举例数独服务器
有一个Sudoku服务器,输入为9*9数字,输出为数独答案,每次求解需要10ms。对于单线程程序,吞吐量上限100qps,多线程达到800qps。如果瞬间有10个请求,对于单线程程序,第一个请求响应时间为10ms,第二个为10ms+10ms,第10个为100ms,平均55ms响应时间。如果用1个IO线程+8个计算线程(线程池),平均为12ms。
实际上,由于题目难度不一,有些简单的题目可能只需要1ms,那么线程池在调度时效率更高,降低简单任务被复杂任务压住的概率。
多线程程序如何让IO和计算相互重叠,降低latency(延迟)?
把IO操作通过任务队列的形式,交给其他线程去做,自己不必等待。
为什么第三方线程通常适用自己的线程?
event loop没有标准实现,第三方库很多代码不容易融入用户代码,最好自己用线程实现,可以做到异步调用。
什么是线程池大小的阻抗匹配原则?
线程池进行密集计算的时间比重为P(0 < P <= 1),处理器核数为C,那么线程池大小T = C / P。举例:线程池的任务一半时间在计算,一半在等待IO,那么T = 16,刚好可以让处理器忙个不停。
如果P过小(< 0.2),公式不适用(线程数太大影响调度),此时可以取上限5*C。
除了Reactor + thread pool,有没有其他non-trivial多线程编程模型?
Proactor可以做到更高的并发度,但是会让代码支离破碎。
举例HTTP proxy(代理)
如果HTTP proxy没有命中cache,会进行:
graph TB
A[解析域名]-->B[建立连接]
B-->C[发送HTTP请求]
C-->D[等待服务响应]
D-->E[回传结果]
过程中有三次round-trip(往返),每一次都会耗时很长:
- DNS解析。
- 和HTTP服务器建立连接。
- 发送HTTP request,等待respone。
每一步运算量不大,用线程池太浪费,2个解决思路:
- 域名解析完成、连接建立完成、响应完成做成3个event,用Reactor编写,这样需要管理过程进行到了第几步。
- 异步回调:
- 发起异步DNS解析startDNSResolve(),完成之后回调DNSResolved()。
- DNSResolved() 中发起TCP连接,连接完成之后回调connEstablished()。
- connEstablished() 中发送request,完成后回调HTTPResponed()。
这种Proactor模式依靠操作系统进行异步操作,IO并发读很高,可以提高吞吐,但是不能降低延迟,并且让代码支离破碎。
模式2和模式3a怎么取舍?
单进程多线程 与 单线程多进程怎么选择,看工作集大小(work set)。工作集就是每次服务响应请求的内存大小。
- 程序有一个较大的cache,缓存一些基础数据,那么多线程好,避免每个进程保留自己的cache。
- memcached的内存消耗大,多线程更好。
- 求数独不需要多少内存,独立性强,单线程更容易编写。