信号量是Linux系统编程中处理并发问题的核心工具之一。记得我第一次在多线程程序中使用信号量时,那种对共享资源的有序控制带来的成就感至今难忘。信号量本质上是一个带计数器的同步原语,它通过简单的P/V操作就能解决复杂的线程同步问题。
与互斥锁相比,信号量的灵活性更高。互斥锁就像是单间厕所,一次只能一个人使用;而信号量更像是公共停车场,可以有多个车位(计数信号量),甚至可以设置为只有一个车位(二值信号量)。这种特性使得信号量在生产者-消费者问题、读写锁实现、线程池管理等场景中表现出色。
信号量的核心是一个计数器加上等待队列。这个计数器记录当前可用资源的数量,而等待队列则保存着因资源不足而阻塞的线程。当线程执行P操作时,内核会原子性地完成以下步骤:
这种原子性保证了即使在高并发场景下,也不会出现两个线程同时看到"还有资源"的假象。我在实际项目中曾遇到过因为不理解这个原子性而导致的bug:一个线程在检查计数器后、执行减1操作前被抢占,结果多个线程都认为资源可用,导致数据竞争。
POSIX标准定义了两种信号量:
在大多数应用中,无名信号量已经足够使用。它的内存管理更简单,性能也更好。但要注意,无名信号量如果是用于进程间同步,必须放在共享内存区域中。
重要提示:信号量初始化后千万不要重复初始化!我在调试一个多线程程序时曾犯过这个错误,导致程序出现难以追踪的随机崩溃。正确的做法是:要么全局只初始化一次,要么使用pthread_once机制。
sem_init函数的正确使用有几个关键点需要注意:
c复制int sem_init(sem_t *sem, int pshared, unsigned int value);
我曾经遇到过一个典型错误:将用于线程同步的信号量声明为局部变量,结果其他线程根本无法访问,导致同步完全失效。
除了基本的sem_wait和sem_post,POSIX还提供了更灵活的操作:
sem_trywait:当资源不可用时立即返回错误,而不是阻塞sem_timedwait:可以设置最大等待时间c复制// 设置3秒超时的示例
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 3;
if (sem_timedwait(&sem, &ts) == -1) {
if (errno == ETIMEDOUT) {
printf("操作超时!\n");
}
}
在实际项目中,带超时的操作特别有用。我曾经开发过一个网络服务,使用信号量控制工作线程数量,通过sem_timedwait实现了优雅的服务器关闭:在收到关闭信号后,等待所有工作线程在指定时间内完成任务退出。
sem_destroy调用看似简单,但有几点容易忽略:
我曾经在项目中出现过信号量资源泄漏的问题,后来发现是因为异常退出路径上没有调用sem_destroy。现在我会在程序初始化时就设计好资源清理的机制。
生产者-消费者问题是信号量最典型的应用场景。我们需要两个信号量:
empty_slots:初始值为缓冲区大小,表示可用空间filled_slots:初始值为0,表示已存放的数据量这种设计确保了:
empty_slots上阻塞filled_slots上阻塞在示例代码中,我们使用简单的链表作为缓冲区。但实际项目中,我通常会采用环形缓冲区,因为它更高效:
c复制#define BUF_SIZE 10
typedef struct {
int data[BUF_SIZE];
int head;
int tail;
sem_t empty;
sem_t filled;
pthread_mutex_t lock; // 保护head/tail的修改
} CircularBuffer;
注意这里增加了一个互斥锁,因为单纯使用信号量无法保护对head/tail指针的并发修改。这是很多初学者容易混淆的地方:信号量控制资源数量,互斥锁保护临界区。
生产者的基本逻辑:
c复制void* producer(void* arg) {
while (1) {
Item item = produce_item();
sem_wait(&empty_slots);
pthread_mutex_lock(&buffer_lock);
// 将item放入缓冲区
enqueue(item);
pthread_mutex_unlock(&buffer_lock);
sem_post(&filled_slots);
}
return NULL;
}
消费者的逻辑与之对称。这种模式有几个关键点:
我曾经遇到过因为锁和信号量顺序不当导致的死锁问题,后来总结出一个原则:先获取信号量,再获取锁;先释放锁,再释放信号量。
调试信号量问题时,我常用的方法有:
sem_getvalue检查信号量当前值info threads查看哪些线程在阻塞在一个高并发服务器项目中,我通过将二值信号量改为计数信号量(初始值设为CPU核心数),使吞吐量提高了近3倍。
| 机制 | 特点 | 适用场景 |
|---|---|---|
| 信号量 | 灵活,支持计数 | 资源池管理,生产者-消费者 |
| 互斥锁 | 简单,性能好 | 简单的临界区保护 |
| 条件变量 | 需要与互斥锁配合使用 | 复杂的状态等待 |
| 读写锁 | 区分读写操作 | 读多写少的场景 |
信号量的优势在于它的灵活性,但并不是所有场景都需要这种灵活性。简单的互斥保护使用互斥锁通常更高效。
屏障(barrier)是一种让多个线程在某个点同步的机制。我们可以用信号量实现:
c复制typedef struct {
sem_t mutex; // 保护计数器
sem_t barrier; // 用于阻塞
int count; // 已到达的线程数
int thread_count; // 需要等待的线程总数
} Barrier;
void barrier_wait(Barrier *b) {
sem_wait(&b->mutex);
b->count++;
if (b->count == b->thread_count) {
// 最后一个到达的线程释放其他线程
for (int i = 0; i < b->thread_count-1; i++) {
sem_post(&b->barrier);
}
b->count = 0; // 重置屏障
sem_post(&b->mutex);
} else {
sem_post(&b->mutex);
sem_wait(&b->barrier); // 等待释放
}
}
这种实现比pthread_barrier更灵活,可以添加额外的逻辑。
读写锁允许多个读或一个写,可以用信号量这样实现:
c复制typedef struct {
sem_t rw_mutex; // 保护写操作
sem_t mutex; // 保护read_count
int read_count;
} RWLock;
void read_lock(RWLock *l) {
sem_wait(&l->mutex);
l->read_count++;
if (l->read_count == 1) {
sem_wait(&l->rw_mutex); // 第一个读者获取写锁
}
sem_post(&l->mutex);
}
void read_unlock(RWLock *l) {
sem_wait(&l->mutex);
l->read_count--;
if (l->read_count == 0) {
sem_post(&l->rw_mutex); // 最后一个读者释放写锁
}
sem_post(&l->mutex);
}
void write_lock(RWLock *l) {
sem_wait(&l->rw_mutex);
}
void write_unlock(RWLock *l) {
sem_post(&l->rw_mutex);
}
这种实现虽然不如系统提供的读写锁高效,但展示了信号量的强大表达能力。
信号量非常适合实现线程池的任务调度:
c复制typedef struct {
Task *task_queue;
sem_t queue_sem; // 可执行任务数
pthread_mutex_t queue_lock;
int queue_size;
int shutdown;
} ThreadPool;
void *worker_thread(void *arg) {
ThreadPool *pool = (ThreadPool *)arg;
while (1) {
sem_wait(&pool->queue_sem); // 等待任务
pthread_mutex_lock(&pool->queue_lock);
if (pool->shutdown) {
pthread_mutex_unlock(&pool->queue_lock);
break;
}
Task task = dequeue(pool);
pthread_mutex_unlock(&pool->queue_lock);
execute_task(task);
}
return NULL;
}
在这个实现中,工作线程通过信号量休眠,当有新任务时由提交任务的线程增加信号量值来唤醒工作线程。