非常精简的Linux线程池实现(一)——使用互斥锁和条件变量

时间:2022-04-06 13:10:14

线程池的含义跟它的名字一样,就是一个由许多线程组成的池子。

有了线程池,在程序中使用多线程变得简单。我们不用再自己去操心线程的创建、撤销、管理问题,有什么要消耗大量CPU时间的任务通通直接扔到线程池里就好了,然后我们的主程序(主线程)可以继续干自己的事去,线程池里面的线程会自动去执行这些任务。

另一方面,线程池提升了多线程程序的性能。我们不需要在大量任务需要执行时现创建大量线程,然后在任务结束时又销毁大量线程,因为线程池里面的线程都是现成的而且能够重复使用。一个理想的线程池能够合理地动态调节池内线程数量,既不会因为线程过少而导致大量任务堆积,也不会因为线程过多了而增加额外的系统开销。

线程池看上去很神奇的样子,那它是怎么实现的呢?线程这么虚渺在的东西也能像有形的物品一样圈在一个池子里?在只知道线程池这个名字的时候,我心里的疑惑就是这样的。

其实线程池的原理非常简单,它就是一个非常典型的生产者消费者同步问题。如果不知道我说的这个XXX问题也不要紧,我下面就解释。

根据刚才描述的线程池的功能,可以看出线程池至少有两个主要动作,一个是主程序不定时地向线程池添加任务,另一个是线程池里的线程领取任务去执行。且不论任务和执行任务是个什么概念,但是一个任务肯定只能分配给一个线程执行。

这样就可以简单猜想线程池的一种可能的架构了:主程序执行入队操作,把任务添加到一个队列里面;池子里的多个工作线程共同对这个队列试图执行出队操作,这里要保证同一时刻只有一个线程出队成功,抢夺到这个任务,其他线程继续共同试图出队抢夺下一个任务。所以在实现线程池之前,我们需要一个队列,我为这个线程池配备的队列单独放到了另一篇博客一个通用纯C队列的实现中。

这里的生产者就是主程序,生产任务(增加任务),消费者就是工作线程,消费任务(执行、减少任务)。因为这里涉及到多个线程同时访问一个队列的问题,所以我们需要互斥锁来保护队列,同时还需要条件变量来处理主线程通知任务到达、工作线程抢夺任务的问题。如果不熟悉条件变量,我在另一篇博客Linux C语言多线程库Pthread中条件变量的的正确用法逐步详解中作了详细说明。

准备工作都差不多了,可以开始设计线程池了。一个最简单线程池应该有什么功能呢?对于使用者来说,除了创建和销毁线程池,最简单的情况下只需要一个功能——添加任务。对于线程池自己来说,最简单的情况下不需要动态调节线程数量,不需要考虑线程同步、线程死锁等等一大堆麻烦的问题。所以最后的线程池API定义为:

  1. //thread_pool.h
  2. #ifndef THREAD_POOL_H_INCLUDED
  3. #define THREAD_POOL_H_INCLUDED
  4. typedef struct thread_pool *thread_pool_t;
  5. thread_pool_t thread_pool_create(unsigned int thread_count);
  6. void thread_pool_add_task(thread_pool_t pool, void* (*routine)(void *arg), void *arg);
  7. void thread_pool_destroy(thread_pool_t pool);
  8. #endif //THREAD_POOL_H_INCLUDED

创建线程池时指定线程池中应该固定包含多少工作线程,添加任务就是向线程池添加一个任务函数指针和任务函数需要的参数——这跟Pthread线程库中的普通线程创建函数pthread_create是一样的。根据这套线程池API,我们使用线程池的应用程序应该是这个套路:

  1. //test.c
  2. #include "thread_pool.h"
  3. #include <stdio.h>
  4. #include <unistd.h>
  5. #include <pthread.h>
  6. void* test(void *arg) {
  7. int i;
  8. for(i=0; i<5; i++) {
  9. printf("tid:%ld task:%ld\n", pthread_self(), (long)arg);
  10. fflush(stdout);
  11. sleep(2);
  12. }
  13. return NULL;
  14. }
  15. int main() {
  16. long i=0;
  17. thread_pool_t pool;
  18. pool=thread_pool_create(2);
  19. for(i=0; i<5; i++) {
  20. thread_pool_add_task(pool, test, (void*)i);
  21. }
  22. puts("press enter to terminate ...");
  23. getchar();
  24. thread_pool_destroy(pool);
  25. return 0;
  26. }

上面这个测试程序向线程池添加了5个相同的任务,每个任务耗时10秒,但是线程池中只有2个工作线程,所以程序的运行结果是两个工作线程轮流把5个任务挨个做完。显示到屏幕上就是:前10秒两个工作线程轮流输出自己的线程ID和当前任务的任务号0和1,各输出5次;第二个10秒两个工作线程轮流输出自己的线程ID和当前任务的任务号2和3……

在这期间,主程序输出“press enter to terminate ...”并等待用户输入,任何时候都可以按回车让主程序继续往下,这样会强制终止所有工作线程并销毁线程池,最后程序退出。test程序运行效果截图如下:

非常精简的Linux线程池实现(一)——使用互斥锁和条件变量

最后就是线程池真正的实现了:

  1. //thread_pool.c
  2. #include "thread_pool.h"
  3. #include "queue.h"
  4. #include <stdlib.h>
  5. #include <pthread.h>
  6. struct thread_pool {
  7. unsigned int thread_count;
  8. pthread_t *threads;
  9. queue_t tasks;
  10. pthread_mutex_t lock;
  11. pthread_cond_t task_ready;
  12. };
  13. struct task {
  14. void* (*routine)(void *arg);
  15. void *arg;
  16. };
  17. static void cleanup(pthread_mutex_t* lock) {
  18. pthread_mutex_unlock(lock);
  19. }
  20. static void * worker(thread_pool_t pool) {
  21. struct task *t;
  22. while(1) {
  23. pthread_mutex_lock(&pool->lock);
  24. pthread_cleanup_push((void(*)(void*))cleanup, &pool->lock);
  25. while(queue_isempty(pool->tasks)) {
  26. pthread_cond_wait(&pool->task_ready, &pool->lock);
  27. /*A condition wait (whether timed or not) is a cancellation point ... a side-effect of acting upon a cancellation request while in a condition wait is that the mutex is (in effect) re-acquired before calling the first cancellation cleanup handler.*/
  28. }
  29. t=(struct task*)queue_dequeue(pool->tasks);
  30. pthread_cleanup_pop(0);
  31. pthread_mutex_unlock(&pool->lock);
  32. t->routine(t->arg);/*todo: report returned value*/
  33. free(t);
  34. }
  35. return NULL;
  36. }
  37. thread_pool_t thread_pool_create(unsigned int thread_count) {
  38. unsigned int i;
  39. thread_pool_t pool=NULL;
  40. pool=(thread_pool_t)malloc(sizeof(struct thread_pool));
  41. pool->thread_count=thread_count;
  42. pool->threads=(pthread_t*)malloc(sizeof(pthread_t)*thread_count);
  43. pool->tasks=queue_create();
  44. pthread_mutex_init(&pool->lock, NULL);
  45. pthread_cond_init(&pool->task_ready, NULL);
  46. for(i=0; i<thread_count; i++) {
  47. pthread_create(pool->threads+i, NULL, (void*(*)(void*))worker, pool);
  48. }
  49. return pool;
  50. }
  51. void thread_pool_add_task(thread_pool_t pool, void* (*routine)(void *arg), void *arg) {
  52. struct task *t;
  53. pthread_mutex_lock(&pool->lock);
  54. t=(struct task*)queue_enqueue(pool->tasks, sizeof(struct task));
  55. t->routine=routine;
  56. t->arg=arg;
  57. pthread_cond_signal(&pool->task_ready);
  58. pthread_mutex_unlock(&pool->lock);
  59. }
  60. void thread_pool_destroy(thread_pool_t pool) {
  61. unsigned int i;
  62. for(i=0; i<pool->thread_count; i++) {
  63. pthread_cancel(pool->threads[i]);
  64. }
  65. for(i=0; i<pool->thread_count; i++) {
  66. pthread_join(pool->threads[i], NULL);
  67. }
  68. pthread_mutex_destroy(&pool->lock);
  69. pthread_cond_destroy(&pool->task_ready);
  70. queue_destroy(pool->tasks);
  71. free(pool->threads);
  72. free(pool);
  73. }

上面的worker函数就是工作线程函数,所有的工作线程都在执行着这个函数。它首先在互斥锁和条件变量的保护下从任务队列中取出一个任务,这个任务实际上是一个函数指针和调用函数所需的参数,所以执行任务就很简单了——用任务参数调用任务函数。函数返回以后,工作线程继续去抢任务。

这里没有处理任务函数的返回值问题,理论上任务函数返回以后线程池应该用某种机制通知主程序,然后主程序获取通过某种手段获取返回值,但这明显不是一个最简单的线程池需要操心的事。实际上,应用程序可以通过全局变量或传入的参数指针,加上额外的线程同步代码解决返回值的通知和获取问题。
还有一点需要注意,最后线程池销毁时会强制终止所有处于撤销点(cacellation
points)的工作线程,如果工作线程正在任务函数中没返回而且任务函数中有非手动创建的撤销点,那么任务函数就会在跑到撤销点时戛然而止,这可能导致意外结果。而如果任务函数中没有任何线程撤销点,那么线程池销毁函数会一直阻塞等待直到任务函数完成后才能终止对应的工作线程并返回。

要正确处理这个问题,线程池使用者必须通过自己的线程同步代码保证调用thread_pool_destroy之前所有任务都已经完成、终止或者取消。