线程池设计中的惊群问题

http://www.myexception.cn/open-source/1587656.html

多线程编程已经是现在网络编程中常用的编程技术,设计一个良好的线程池库显得尤为重要。在 UNIX(WIN32下可以采用类似的方法,acl 库中的线程池是跨平台的) 环境下设计线程池库主要是如何用好如下系统 API:

      1、pthread_cond_signal/pthread_cond_broadcast:生产者线程通知线程池中的某个或一些消费者线程池,接收处理任务;

      2、pthread_cond_wait:线程池中的消费者线程等待线程条件变量被通知;

      3、pthread_mutex_lock/pthread_mutex_unlock:线程互斥锁的加锁及解锁函数。

 

      下面的代码示例是大家常见的线程池的设计方式:

 

// 线程任务类型定义
struct thread_job {
	struct thread_job *next;  // 指向下一个线程任务
	void (*func)(void*);      // 应用回调处理函数 
	void *arg;                // 回调函数的参数
	...
};

// 线程池类型定义
struct thread_pool {
	int   max_threads;        // 线程池中最大线程数限制
	int   curr_threads;       // 当前线程池中总的线程数
	int   idle_threads;       // 当前线程池中空闲的线程数
	pthread_mutex_t mutex;    // 线程互斥锁
	pthread_cond_t  cond;     // 线程条件变量
	thread_job *first;        // 线程任务链表的表头
	thread_job *last;         // 线程任务链表的表尾
	...	
}

// 线程池中的消费者线程处理过程
static void *consumer_thread(void *arg)
{
	struct thread_pool *pool = (struct thread_pool*) arg;
	struct thread_job  *job;
	int   status;

	// 该消费者线程需要先加锁
	pthread_mutex_lock(&pool->mutex);

	while (1) {
		if (pool->first != NULL) {
			// 有线程任务时,则取出并在下面进行处理
			job = pool->first;
			pool->first = job->next;
			if (pool->last == job)
				pool->last = NULL;

			// 解锁,允许其它消费者线程加锁或生产者线程添加新的任务
			pthread_mutex_unlock(&pool->mutex);

			// 回调应用的处理函数
			job->func(job->arg);

			// 释放动态分配的内存
			free(job);

			// 重新去加锁
			pthread_mutex_lock(&pool->mutex);
		} else {
			pool->idle_threads++;

			// 在调用 pthread_cond_wait 等待线程条件变量被通知且自动解锁
			status = pthread_cond_wait(&pool->cond, &pool->mutex);

			pool->idle_threads--;

			if (status == 0)
				continue;

			// 等待线程条件变量异常,则该线程需要退出
			pool->curr_threads--;
			pthread_mutex_unlock(&pool->mutex);
			break;
		}
	}

	return NULL;
}

// 生产者线程调用此函数添加新的处理任务
void add_thread_job(struct thread_pool *pool, void (*func)(void*), void *arg)
{
	// 动态分配任务对象
	struct thread_job *job = (struct thread_job*) calloc(1, sizeof(*job));

	job->func = func;
	job->arg = arg;

	pthread_mutex_lock(&pool->mutex);

	// 将新任务添加进线程池的任务链表中
	if (pool->first == NULL)
		pool->first = job;
	else
		pool->last->next = job;
	pool->last = job;
	job->next = NULL;
	
	if (pool->idle_threads > 0) {
		// 如果有空闲消费者线程,则通知空闲线程进行处理,同时需要解锁

		pthread_mutex_unlock(&pool->mutex);
		pthread_cond_signal(&pool->cond);
	} else if (pool->curr_threads < pool->max_threads) {
		// 如果未超过最大线程数限制,则创建一个新的消费者线程

		pthread_t id;
		pthread_attr_t attr;

		pthread_attr_init(&attr);

		// 将线程属性设为分享模式,这样当线程退出时其资源自动由系统回收
		pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

		// 创建一个消费者线程
		if (pthread_create(&id, &attr, consumer_thread, pool) == 0)
			pool->curr_threads++;

		pthread_mutex_unlock(&pool->mutex);
		pthread_attr_destroy(&attr);
	}
}

// 创建线程池对象
struct thread_pool *create_thread_pool(int max_threads)
{
	struct thread_pool *pool = (struct thread_pool*) calloc(1, sizeof(*pool));
	
	pool->max_threads = max_threads;
	...

	return pool;
}

///////////////////////////////////////////////////////////////////////////////////
// 使用上面线程池的示例如下:

// 由消费者线程回调的处理过程
static void thread_callback(void* arg)
{
      ...
}

void test(void)
{
	struct thread_pool *pool = create_thread_pool(100);
	int   i;

	// 循环添加 1000000 次线程处理任务
	for (i = 0; i < 1000000; i++)
		add_thread_job(pool, thread_callback, NULL);
}

 

      乍一看去,似乎也没有什么问题,象很多经典的开源代码中也是这样设计的,但有一个重要问题被忽视了:线程池设计中的惊群现象。大家可以看到,整个线程池只有一个线程条件变量和线程互斥锁,生产者线程和消费者线程(即线程池中的子线程)正是通过这两个变量进行同步的。生产者线程每添加一个新任务,都会调用 pthread_cond_signal 一次,由操作系统唤醒一个在线程条件变量等待的消费者线程,但如果查看 pthread_cond_signal API 的系统帮助,你会发现其中有一句话:调用此函数后,系统会唤醒在相同条件变量上等待的一个或多个线程。而正是这句模棱两可的话没有引起很多线程池设计者的注意,这也是整个线程池中消费者线程收到信号通知后产生惊群现象的根源所在,并且是消费者线程数量越多,惊群现象越严重----意味着 CPU 占用越高,线程池的调度性能越低。

      要想避免如上线程池设计中的惊群问题,在仍然共用一个线程互斥锁的条件下,给每一个消费者线程创建一个线程条件变量,生产者线程在添加任务时,找到空闲的消费者线程,将任务置入该消费者的任务队列中,同时只通知 (pthread_cond_signal) 该消费者的线程条件变量,消费者线程与生产者线程虽然共用相同的线程互斥锁(因为有全局资源及调用 pthread_cond_wait 所需,并且linux互斥锁每次只会唤醒一个线程),但线程条件变量的通知过程却是定向通知的,未被通知的消费者线程不会被唤醒,这样惊群现象也就不会产生了。

      当然,还有一些设计上的细节需要注意,比如:当没有空闲消费者线程时,需要将任务添加进线程池的全局任务队列中,消费者线程处理完自己的任务后需要查看一下线程池中的全局任务队列中是否还有未处理的任务。

      更多的线程池的设计细节请参考 acl (https://sourceforge.net/projects/acl/) 库中 lib_acl/src/thread/acl_pthread_pool.c 中的代码。

 

 参考:

线程编程常见API简介(上)

线程编程常见API简介(中)

线程编程常见API简介(下)

使用 acl_cpp 库编写多线程程序

利用ACL库开发高并发半驻留式线程池程序

多线程开发时线程局部变量的使用

再谈线程局部变量

 

acl 库下载:https://sourceforge.net/projects/acl/

github:https://github.com/zhengshuxin/acl

svn:svn checkout svn://svn.code.sf.net/p/acl/code/trunk acl-code

qq 群:242722074
<think>我们正在讨论如何避免使用`pthread_cond_broadcast`时出现的效应(thunderingherd problem)。效应指的是当多个线程等待同一个条件变量时,广播唤醒所有线程,但只有一个线程能获得资源(例如锁),其他线程又立即进入睡眠,导致不必要的上下文切换和性能下降。参考站内引用[1]的内容与当前问题无关,因此我们忽略它。针对如何避免效应,我们可以采取以下策略:1. **使用多个条件变量**:将等待的线程分组,每个组使用不同的条件变量。这样,当需要唤醒线程时,可以根据情况只广播特定组的条件变量,而不是全部线程。这种方法适用于线程有不同任务类型的情况。2.**使用`pthread_cond_signal`替代广播**:在可能的情况下,尽量使用`pthread_cond_signal`每次只唤醒一个线程。这要求我们能够精确控制唤醒的线程数量。例如,在生产者-消费者模型中,如果生产者每次只生产一个资源,则只需唤醒一个消费者。3. **使用队列管理唤醒顺序**:维护一个显式的等待线程队列(例如使用链表),然后根据需要唤醒特定数量的线程(通过`pthread_cond_signal`多次)。这样,我们可以避免唤醒所有线程。4.**使用信号量(semaphore)**:信号量可以更自然地控制多个线程的唤醒数量。每个线程在等待资源前先尝试减少信号量,如果信号量为0则阻塞。当资源可用时,增加信号量,系统会自动唤醒一个等待线程(或者可以指定唤醒数量)。POSIX信号量(`sem_t`)提供了`sem_post`(增加信号量,唤醒一个线程)和`sem_wait`(减少信号量,如果为0则阻塞)操作。5.**设计避免全局广播的条件**:在代码逻辑上,确保只有真正需要被唤醒的线程才会被唤醒。例如,在条件变量上等待时,每个线程检查不同的条件(例如任务队列中是否有特定类型的任务),然后广播只针对那些可能满足条件的线程组。下面我们通过一个具体的例子来说明如何避免效应:###示例:使用多个条件变量避免效应假设我们有一个线程池,其中包含两种类型的任务:高优先级和低优先级。我们可以为每种优先级设置一个条件变量,这样在添加任务时,根据任务的优先级只唤醒等待该优先级任务的线程。```ctypedefstruct {pthread_mutex_t lock;pthread_cond_thigh_priority_cond;//高优先级条件变量pthread_cond_tlow_priority_cond;//低优先级条件变量Task* high_priority_queue;//高优先级任务队列Task* low_priority_queue;//低优先级任务队列} ThreadPool;//工作线程函数void*worker_thread(void*arg){ThreadPool* pool= (ThreadPool*)arg;while (1){pthread_mutex_lock(&pool->lock);//优先处理高优先级队列while(pool->high_priority_queue ==NULL &&pool->low_priority_queue== NULL) {//如果没有任务,等待任意一个条件变量(这里使用一个条件变量等待,但实际中可能需要更复杂的等待逻辑)//为了简化,我们假设线程可以处理两种任务,但优先处理高优先级//所以我们可以先等待高优先级条件变量,超时后再检查低优先级(或者使用pthread_cond_wait的变种)//但为了清晰,我们这里使用两个条件变量,并分别等待//注意:实际中可能需要避免同时等待多个条件变量,这里我们采用先检查高优先级,没有则等待低优先级if (pool->high_priority_queue) {break;} else{pthread_cond_wait(&pool->low_priority_cond,&pool->lock);}}//如果有高优先级任务,先处理高优先级if(pool->high_priority_queue !=NULL){Task task= pop_high_priority_task(pool);pthread_mutex_unlock(&pool->lock);execute_task(task);} elseif (pool->low_priority_queue!= NULL) {Tasktask =pop_low_priority_task(pool);pthread_mutex_unlock(&pool->lock);execute_task(task);}else {//这里应该不会发生,因为我们在while循环中等待pthread_mutex_unlock(&pool->lock);}}return NULL; }//添加任务函数void add_task(ThreadPool* pool, Tasknew_task,int priority) {pthread_mutex_lock(&pool->lock);if(priority== HIGH_PRIORITY) {push_high_priority_task(pool,new_task);//只唤醒一个线程来处理高优先级任务(因为可能只有一个高优先级任务)pthread_cond_signal(&pool->high_priority_cond);} else{push_low_priority_task(pool, new_task);//对于低优先级任务,我们可以考虑唤醒一个线程(或者如果低优先级任务很多,可以广播,但这里我们避免广播)pthread_cond_signal(&pool->low_priority_cond);}pthread_mutex_unlock(&pool->lock);} ```在这个例子中,我们使用两个条件变量,并且每次添加任务时只唤醒一个线程(使用`pthread_cond_signal`),从而避免效应。 ###使用信号量替代条件变量信号量可以更直接地控制唤醒线程的数量。例如,我们可以初始化一个信号量,其初始值为0。每个工作线程在开始工作前尝试减少信号量(`sem_wait`),如果没有任务则阻塞。当有新任务时,增加信号量(`sem_post`)来唤醒一个线程。```c#include<semaphore.h> typedefstruct {sem_t sem;//信号量pthread_mutex_tlock;Task* task_queue;} ThreadPool;void*worker_thread(void*arg){ThreadPool* pool= (ThreadPool*)arg;while (1){sem_wait(&pool->sem);//等待信号量(如果没有任务,则阻塞)pthread_mutex_lock(&pool->lock);Tasktask =pop_task(pool);pthread_mutex_unlock(&pool->lock);execute_task(task);} }void add_task(ThreadPool* pool, Tasknew_task){pthread_mutex_lock(&pool->lock);push_task(pool, new_task);pthread_mutex_unlock(&pool->lock);sem_post(&pool->sem);//增加信号量,唤醒一个线程}``` 这种方法简单且避免效应,因为每次添加任务只唤醒一个线程。###总结避免效应的策略|**策略**|**适用场景**| **优点**|**缺点**| |------------------------------|--------------------------------------------|----------------------------------|----------------------------------------| |使用多个条件变量|线程有不同任务类型或优先级|精确控制唤醒的线程组|增加代码复杂度||使用`pthread_cond_signal`|每次只需要唤醒一个线程的场景|简单,避免不必要唤醒|无法满足需要唤醒多个线程的场景||显式线程队列管理|需要按特定顺序或数量唤醒线程|完全控制唤醒的线程|实现复杂,需要维护额外数据结构| |使用信号量|简单任务队列,无需复杂条件检查|实现简单,自动控制唤醒数量|不适用于需要复杂条件检查的场景|>注意:在某些情况下,效应可能无法完全避免,但我们可以通过上述方法减少其影响。例如,在Linux内核中,epoll使用互斥锁和条件变量时,通过只唤醒一个等待进程/线程来避免效应[^2]。 §§相关问题§§1.在Linux内核中,epoll是如何避免效应的?2.除了条件变量,还有哪些同步原语容易导致效应?3.如何设计一个线程池以最小化线程唤醒开销?</think>### 如何避免 `pthread_cond_broadcast` 导致的效应 效应(Thundering Herd Problem)指当多个线程等待同一条件变量时,`pthread_cond_broadcast` 唤醒所有线程,但只有一个线程能获取资源,其他线程立即重新进入等待状态,造成不必要的上下文切换和性能下降。以下是有效的解决方案: --- #### 1. **使用 `pthread_cond_signal` 替代广播** **核心思路**:仅唤醒一个线程而非所有线程 ```c // 生产者代码 void add_resource() { pthread_mutex_lock(&mutex); resource_available = 1; // 仅唤醒一个线程 pthread_cond_signal(&cond); // 替代 broadcast pthread_mutex_unlock(&mutex); } ``` - **适用场景**:资源一次只能被一个线程使用(如单任务队列) - **效果**:减少 $N-1$ 次无效唤醒($N$ 为等待线程数) --- #### 2. **使用多个条件变量分组唤醒** **核心思路**:根据线程类型或任务分组,使用独立条件变量 ```c // 定义分组条件变量 pthread_cond_t high_priority_cond; pthread_cond_t low_priority_cond; // 高优先级生产者 void add_high_priority_task() { pthread_mutex_lock(&mutex); // ... 添加任务 pthread_cond_signal(&high_priority_cond); // 仅唤醒高优先级组 pthread_mutex_unlock(&mutex); } ``` - **优势**:避免低优先级线程被无效唤醒 - **适用场景**:多级任务队列(如实时任务+普通任务) --- #### 3. **显式线程队列管理** **核心思路**:维护等待线程队列,按需唤醒特定线程 ```c // 自定义线程队列 typedef struct { pthread_cond_t cond; int is_awake; } ThreadSlot; ThreadSlot slots[MAX_THREADS]; // 唤醒特定线程 void wake_specific_thread(int id) { pthread_mutex_lock(&mutex); slots[id].is_awake = 1; pthread_cond_signal(&slots[id].cond); // 仅唤醒目标槽位 pthread_mutex_unlock(&mutex); } ``` - **优势**:精确控制唤醒目标,完全避免 - **代价**:增加队列管理复杂度 --- #### 4. **使用信号量(Semaphore)替代条件变量** **核心思路**:信号量天然支持计数唤醒 ```c #include <semaphore.h> sem_t resource_sem; // 初始化:sem_init(&resource_sem, 0, 0) // 消费者 void consumer() { sem_wait(&resource_sem); // 阻塞直到资源可用 // ... 处理资源 } // 生产者 void producer() { // ... 生产资源 sem_post(&resource_sem); // 仅唤醒一个线程 } ``` - **优势**: - 自动避免(`sem_post` 保证单线程唤醒) - 无需配合互斥锁使用 - **适用场景**:资源计数型同步 --- #### 5. **条件变量 + 唤醒阈值控制** **核心思路**:根据可用资源数量动态选择唤醒方式 ```c void notify_consumers(int resource_count) { pthread_mutex_lock(&mutex); if (resource_count == 1) { pthread_cond_signal(&cond); // 单资源唤醒一个 } else if (resource_count > 1) { // 按需唤醒部分线程 for (int i=0; i<min(resource_count, waiting_threads); i++) { pthread_cond_signal(&cond); } } pthread_mutex_unlock(&mutex); } ``` - **关键优化**: - 维护 `waiting_threads` 计数器(在 `pthread_cond_wait` 前后增减) - 唤醒线程数 $= \min(\text{资源数}, \text{等待线程数})$ --- #### 性能对比表 | **方法** | 唤醒线程数 | 上下文切换 | 实现复杂度 | 适用场景 | |------------------------|------------|------------|------------|------------------------| | 原始 `broadcast` | $N$ | 高 | 低 | 极简场景 | | `signal` 替代 | $1$ | 最低 | 低 | 单资源消费 | | 分组条件变量 | $\leq N$ | 中 | 中 | 多优先级任务 | | 显式线程队列 | $1$ | 最低 | 高 | 需精确控制 | | 信号量 | $1$ | 最低 | 低 | 计数型资源 | | 阈值控制 | $\min(R,W)$| 低 | 中 | 资源数动态变化 | > **最佳实践**: > - 优先考虑信号量(简单场景)或 `pthread_cond_signal` > - 高并发系统使用显式线程队列(如Linux内核的epoll)[^1] > - 避免在循环中无条件使用 `broadcast` --- #### 真实案例:Nginx中的避免 Nginx使用显式唤醒队列解决监听socket的问题: ```c // 伪代码:基于epoll的唤醒机制 void ngx_process_events() { // 1. 每个worker线程在epoll_wait()上阻塞 int n = epoll_wait(epfd, events, MAX_EVENTS, timeout); // 2. 内核确保仅唤醒一个线程处理新连接 for (int i=0; i<n; i++) { handle_event(events[i]); } } ``` 通过内核级的唤醒优化,完全避免应用层[^1]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值