文章目录
多线程服务器的适用场合与常用编程模型
3.1 进程与线程
一个进程是“内存中正在运行的程序”,每个进程有自己独立的地址空间。“在同一个进程”还是“不再同一个进程”,是系统功能划分的重要决策点。
线程的特点是共享地址空间,从而可以高效地共享数据。一台机器上的多个进程能高效地共享代码段(操作系统可以映射为同样的物理内存),但不能共享数据。
3.2 单线程服务器的常用编程模型
在高性能的网络程序中,使用得最广泛得是non-blocking I/O + I/O multiplexing
这种模型,即Reactor
模型。
在non-blocking I/O + I/O multiplexing
这种模式中,程序的基本结构是一个事件循环,以事件驱动和事件回调的方式实现业务逻辑。
while(!done)
{
int timeout_ms = max(1000, getNextTimeCallback());
int retval = ::poll(fds, nfds, timeout_ms);
if(retval < 0)
{
//处理错误,回调用户的error handler
}
else
{
//处理到期的timer,回调用户的timer handler
if(retval > 0)
//处理I/O事件,回调用户的IO event handler
}
}
3.3 多线程服务器的常用编程模型
3.3.1 one loop per thread
在此种模型下,程序里的每个I/O线程都有一个event loop
,用于处理读写和定时事件。
这种方式的好处是:
- 线程数目基本固定,可以在程序启动的时候设置,不会频繁创建与销毁。
- 可以很方便地在线程间调配负载。
- IO事件发生的线程是固定的,同一个TCP连接不必考虑事件并发。
Eventloop
代表了线程的主循环,需要让哪个线程干活,就把timer
或IO channel
注册到哪个线程的loop
里即可。对实时性有要求的connection
可以单独用一个线程;数据量大的connection
可以独占一个线程,并把数据处理任务分摊另几个计算线程中(用线程池);其他次要的辅助性connections
可以共享一个线程。
对于non-trivial
的服务端程序,一般会采用non-blocking I/O + I/O multiplexing
,每个connection/acceptor
都会注册到某个event loop
上,程序里有多个event loop
,每个线程至多有一个event loop
。
3.3.2 线程池
对于没有I/O而光有计算任务的线程,使用event loop
有点浪费,用一种补充方案,用blocking queue
实现的任务队列。
typedef boost::function<void()> Functor;
BlockingQueue<Functor> taskQueue; //线程安全的阻塞队列
void workerThread()
{
while(running)
{
Functor task = taskQueue.take(); //this blocks
task();
}
}
用这种方式实现线程池特别容易,以下是启动容量为N的线程池。
int N = num_of_computing_threads;
for(int i = 0; i < N; i++)
{
create_thread(&workThread);
}
使用起来也简单
Foo foo;
boost::funtion<void()> task = boost::bind(&Foo::calc, &foo);
taskQueue.post(task);
3.3.3 推荐模式
推荐的C++多线程服务端编程模式为one loop per thread + thread pool
。
event loop
用作IO multiplexing
,配合non-blocking IO
和定时器thread pool
用来做计算,具体可以是任务队列或生产者消费者队列。
3.5 多线程服务器的适用场合
开发服务端程序的一个基本任务是处理并发连接,现在服务端网络编程处理并发连接主要有两种方式。
- 当“线程”很廉价时,一台机器上可以创建远高于CPU数目的“线程”。这时一个线程只处理一个TCP连接,通常使用阻塞IO。
- 当线程很宝贵时,一台机器上只能创建与CPU数目相当的线程。这时一个线程要处理多个TCP连接上的IO,通常使用非阻塞的IO和IO multiplexing。
3.5.1 必须用单线程的场合
有两种场合必须使用单线程
- 程序可能会
fork()
; - 限制程序的CPU占用率;
3.5.2 单线程程序的优缺点
从编程的角度,单线程程序的优点是:简单。
Event loop
有一个明显的缺点,它是非抢占的。假设事件a的优先级高于事件b,处理事件a需要1ms,处理事件b需要10ms。如果事件b稍早于a发生,那么当事件a到来时,程序已经离开了poll()
的调用,并开始处理事件b。事件a要等上10ms才有机会被处理,总的响应时间为11ms。这相当于发生了优先级反转。这个缺点可以使用多线程来客服。
3.5.3 使用多线程程序的场景
多线程的适用场景是:提高相应速度,让IO和“计算”相互重叠,降低延迟。虽然多线程不能提高绝对性能,但是能提高平均响应性能。
一个程序要做成多线程的,大致要满足:
- 有多个CPU可用,单核机器上多线程没有性能优势。
- 线程间有共享数据,即内存中的全局状态。
- 共享的数据是可以修改的,而不是静态的常量表。如果数据不能修改,那么可以在进程间用
shared memory
。 - 提供非均质的服务。即,事件的响应有优先级差异,我们可以用专门的线程来处理优先级高的事件。防止优先级反转。
- 多线程能有效地划分责任与功能,让每个线程的逻辑比较简单,任务单一,便于编码。而不是把所有的逻辑都塞到
event loop
里面,不同类别的事件之间相互影响。
线程的分类
一个多线程服务程序中的线程大致可以分为3类。
- IO线程,这类线程的主循环是
IO multiplexing
,阻塞地等在select/poll/epoll_wait
系统调用上。这类线程也处理定时事件。当然它的功能不止IO。 - 计算线程,这类线程的主循环是
blocking queue
,阻塞地等在condition variable
上,这类线程一般位于thread pool
中。这种线程通常不涉及IO,一般要避免任何阻塞操作。 - 第三方库所用的线程,比如
logging
。
3.6 多线程服务器的适用场合例释和答疑
1.Linux能同时启动多少个线程
对于32-bit Linux,一个进程的地址空间是4GB,其中用户态能访问3GB左右,而一个线程的默认栈大小是10MB,一个进程大约最多能同时启动300个线程。
2.多线程能提高并发度么?
如果指的是“并发连接数”,则不能。
假如单纯采用thread per connection
的模型,那么并发连接数最多300。采用前文推荐的one loop per thread
,至少不逊于单线程程序。
小结:thread per connection
不适合高并发场合,其scalability
不佳,one loop per thread
的并发度足够大,且与CPU数目成正比。
3.多线程能提高吞吐量
对于计算密集型服务,不能。
4.多线程能减低响应时间么?
如果设计合理,充分利用多核资源的话,可以。在突发请求时效果尤为明显。
例如:多线程处理输入
- 读取并解析客户端输入。
- 操作
hashtable
。 - 返回客户端。
在单线程模式下,这3步是串行执行的。在启用多线程模式时,它会启用多个输入线程(默认是4个),并在建立连接时按round-robin
法把新连接分派给其中一个输入线程,这就是one loop per thread
模型。这样一来,第1步的操作就能多线程并行,在多核机器上提高多用户的响应速度。第2步用了全局锁。
5.多线程程序如何让IO和“计算”相互重叠,降低延迟
基本思路是,把IO操作通过BlockingQueue
交给别的线程去做,自己不必等待。
例1:日志。
在一次请求响应中,可能要写多条日志消息,而如果采用同步的方式写文件,多半会降低性能,因为:
- 文件操作一般比较慢,服务线程会等在IO上,让CPU闲置,增加响应时间。
- 就算有
buffer
,也不行。多个线程一起写,为了不至于把buffer
写错乱,往往需要加锁。这样会让服务线程互相等待,降低并发度。
解决方法是单独用一个logging
线程,负责写磁盘文件,通过一个或多个BlockingQueue
对外提供接口。别的线程要写日志的时候,先把消息准备好,然后往queue
里一塞就行,基本不用等待。这样服务线程的计算就和logging
线程的磁盘IO相互重叠,降低了服务线程的响应时间。