Thinking on thread pool

大多数应用程序的线程池的实现都是纯用户态的。这其中有两个例外,微软的Windows new ThreadPool API和苹果的GCD.

Availability:

首次发布的系统时间
Windows New Thread Pool APIVista2006年
GCDOS X 10.62009年

(Windows还有一套老的ThreadPool API,是从XP开始提供的,那个是纯用户态的)

传统来说,线程池的实现非常简单,启动几个线程,然后配合一个线程安全的队列就可以了。大约只需要几百行代码,利用C++标准库现有的thead/mutex/condition_variable这些原语就很容易实现一个跨操作系统的线程池。

但是往往,我们会发现同一进程里会有多个线程池,它们分别是针对不同的业务逻辑需要。于是如何设置每个线程池的大小就成了一个经验性的问题。如果我们可以实现一个通用的线程池,使得进程可以把它所有不同的workload都混在这同一个线程池中执行,同时这个线程池可以自动调节活跃线程数量使其等于CPU物理核数,那么这就是最理想的。

如果线程池收到的每个任务,在执行的时候都不会有任何阻塞或者等待,也就是说每时每秒都在进行纯CPU计算。那么我们线程的创建/销毁的策略就非常简单:让线程数量静态的等于这台机器总的CPU核数。这样就是最优的。

但是实际上用户是有可能进行阻塞式的操作,如:
1.  锁竞争
2.  等待某个事件(如条件变量,Windows Event)
3.  阻塞式IO

理想情况下不应该有任何阻塞性操作。但是实际上你常常会调用一些第三方的库(如MySQL Client),而它们不支持异步IO或非阻塞式IO,把它代码彻底改写又不大可能。

另外,当所有的线程都处于blocking状态时,就会导致新来的task无法被处理。我们称之为饥饿(starvation)。最严重的情况下,整个线程池里所有的线程都陷入无限的等待中,类似于死锁。为了防止这样的事情发生,需要一些简单粗暴的编程规则,如:等待task完成的线程不能与执行该task的线程在同一个线程池里。

出于以下几种原因,我们会希望使用操作系统特殊的API,而不是C++标准库的那些thread/mutex/condition_variable来实现线程池。

1. 让活跃的线程数量可以根据IO负载来调节。如果一个线程陷入IO等待不再占用CPU,于是我们就可以释放另一个线程去执行计算。
2. 多路复用。但是条件变量有一个很大的缺点是它不支持多路复用。一个线程不可能同时等待在多个条件变量上。
3. 让线程的唤醒实现LIFO。即最后一个进入空闲状态的线程最先被唤醒。


那么如何让存在阻塞式IO的情况下CPU的利用率更优?比如,系统一旦发现有个线程陷入等待,就释放另一个线程去进行计算,尽管这样会导致线程总数大于CPU核数。在Windows上,对这个问题传统的做法是利用IO完成端口。

IO完成端口是一种特殊的队列。每个IO完成端口对应一组文件句柄和一组线程。这些文件句柄和线程的创建和销毁都是由用户自己来做。IO完成端口主要封装了两件事情:

1. 入队列:队列里的每个任务都是来自于这些文件句柄对应的IO事件
2. 出队列:是靠用户在线程池中自行调用GetQueuedCompletionStatus函数来取出新的任务。只有线程池中活跃的线程数量小于指定数量时,才允许任务被取出。这个指定数量是创建IO端口时给的NumberOfConcurrentThreads参数,通常等于CPU核数。并发控制完全是靠GetQueuedCompletionStatus这个函数什么时候返回来做的。

在这种模式下,线程池的大小通常是一个固定的值,但是远大于CPU核数。比如明明只有4个核,但是我们创建10个线程。于是即便有6个线程同时等待在IO上,另外4个也可以继续充分的利用CPU进行计算。但是,一旦那些IO完成了,那么同时处于活跃状态的线程数量就可能会大于CPU核数,从而造成不必要的抢占和CPU上下文切换。一个典型的场景是:一个http server,如果同时收到很多请求,访问某一个不在内存cache中的冷资源。假设这个http server就是使用普通的fopen/fread,用了很多个线程同时去读这个文件。那么,一旦某个线程读到了,这个文件就从硬盘到了操作系统的cache里,其它线程的读取请求也就会瞬间返回。于是,大量的线程在同一个时刻从IO等待的状态回到了可运行的状态,去抢占CPU。

Windows Threadpool可以认为是对IO完成端口的再次封装,让用户不用关心线程和完成端口的
创建和销毁。

传统的Threadpool的实现一般只有一个队列。Windows从Win7开始,开始加入对任务优先级(Priority)的支持。一共三种优先级:High, Normal, Low。现在队列是针对每种优先级每个NUMA Node有一个。 GCD也是有多个队列。这种多队列的设计思想才是正途。

Windows Threadpool API没有给用户预留任何接口去指定IO完成端口的Concurrency(aka. NumberOfConcurrentThreads). 这个Concurrency永远是根据硬件配置来决定的。用户只能通过设置线程池的min/max size来控制Concurrency。

线程的创建和销毁是由一个叫做worker factory的内核对象来管理的。每个thread pool对应一个worker factory内核对象。创建worker factory是通过NtCreateWorkerFactory函数,需要提供一个IO完成端口。

Windows Threadpool可以同时处理4种内核对象:work object, timer object, wait object, IO object.




评论

此博客中的热门博文

想换个新路由器

这几天玩快手玩的入迷

用java生tensorflow的tfrecord文件