文章目录
- 线程同步与互斥
-
- 为什么需要同步与互斥
- [1. 线程互斥](#1. 线程互斥)
-
- [1.1 问题引入](#1.1 问题引入)
- [1.2 互斥量(mutex)](#1.2 互斥量(mutex))
-
- [1.2.1 互斥量 API](#1.2.1 互斥量 API)
- [1.2.2 互斥量实现原理](#1.2.2 互斥量实现原理)
- [1.2.3 互斥量的封装](#1.2.3 互斥量的封装)
- [2. 线程同步](#2. 线程同步)
-
- [2.1 同步概念](#2.1 同步概念)
- [2.2 条件变量](#2.2 条件变量)
-
- [2.2.1 条件变量 API](#2.2.1 条件变量 API)
- [2.2.2 生产消费模型](#2.2.2 生产消费模型)
- [2.2.3 基于阻塞队列的生产者消费者模型](#2.2.3 基于阻塞队列的生产者消费者模型)
-
- [2.2.3.1 阻塞队列实现](#2.2.3.1 阻塞队列实现)
- [2.2.3.2 基础测试验证](#2.2.3.2 基础测试验证)
- [2.2.3.3 任务处理扩展](#2.2.3.3 任务处理扩展)
- [2.2.3.4 多生产多消费场景](#2.2.3.4 多生产多消费场景)
- [2.2.3.5 小结](#2.2.3.5 小结)
- [2.2.3.6 补充 --- 为什么 pthread_cond_wait 需要互斥锁?](#2.2.3.6 补充 --- 为什么 pthread_cond_wait 需要互斥锁?)
- [2.2.4 条件变量的封装](#2.2.4 条件变量的封装)
- [2.3 POSIX 信号量](#2.3 POSIX 信号量)
-
- [2.3.1 System V && POSIX](#2.3.1 System V && POSIX)
- [2.3.2 信号量 API (POSIX 版本)](#2.3.2 信号量 API (POSIX 版本))
- [2.3.3 基于环形队列的生产者消费者模型](#2.3.3 基于环形队列的生产者消费者模型)
-
- [2.3.3.1 环型队列实现](#2.3.3.1 环型队列实现)
- [2.3.3.2 单生产单消费场景](#2.3.3.2 单生产单消费场景)
- [2.3.3.3 多生产多消费场景](#2.3.3.3 多生产多消费场景)
- [2.3.3.4 小结](#2.3.3.4 小结)
- [2.3.4 信号量的封装](#2.3.4 信号量的封装)
- [3. 总结](#3. 总结)
-
- [3.1 多线程使用资源的两种核心场景](#3.1 多线程使用资源的两种核心场景)
-
- [3.1.1 整体资源使用模式](#3.1.1 整体资源使用模式)
- [3.1.2 分块资源使用模式](#3.1.2 分块资源使用模式)
- [3.2 信号量的本质理解](#3.2 信号量的本质理解)
-
- [3.2.1 第一层本质:资源计数器与预定机制](#3.2.1 第一层本质:资源计数器与预定机制)
- [3.2.2 第二层本质:前置条件判断的原子化](#3.2.2 第二层本质:前置条件判断的原子化)
- [3.2.3 第三层本质:信号量自身的临界性](#3.2.3 第三层本质:信号量自身的临界性)
- [3.3 两种模型的对比](#3.3 两种模型的对比)
- [3.4 并发控制的两种基本思路](#3.4 并发控制的两种基本思路)
- [4. 线程池](#4. 线程池)
-
-
- [4.1 日志模块](#4.1 日志模块)
- [4.2 线程池](#4.2 线程池)
-
线程同步与互斥
为什么需要同步与互斥
当多个线程同时运行时,它们可能会访问共享资源 (如全局变量、静态对象、堆内存、文件等)。由于线程是并发执行的,我们无法确定不同线程指令的交错顺序,这会导致两类问题:竞态条件(Race Condition)和数据竞争(Data Race),下面通过一个 "抢票" 例子来说明什么是竞态条件和数据竞争
🌰
假设某场演唱会还剩最后 1 张票 tickets。服务器程序开了两个线程(Thread-A 和 Thread-B)来处理同时到达的两个用户的请求
正常的流程应该是:
- 查看余票数(现在是1)
- 如果 > 0,则卖出一张票
- 余票数减1,变成0
其实竞态条件和数据竞争,更像是同一个问题的两种不同描述
- 竞态条件 (Race Condition - 逻辑错误) :结果的正确性,取决于线程跑的 "速度" 和 "timing(时机)"
tickets的最终值(是0还是-1)是不确定的,它完全取决于Thread-A和Thread-B三条指令(读、判、写)的交错顺序- 一种好的时序(结果正确
tickets=0): - A:读(1) -> A:判(真) -> A:写(0)
- B:读(0) -> B:判(假) -> B:不买票
- 一种好的时序(结果正确
- 一种坏的时序(结果错误
tickets=-1):- A:读(1)
- B:读(1) // B在A写入前读取了旧值
- A:判(真) -> A:写(0)
- B:判(真) -> B:写((将A写的0读入再减减)-1) // B的写入覆盖了A的写入
- 数据竞争 (Data Race - 内存访问冲突) :两个以上的线程,不管三七二十一,同时去 "写" 同一个东西
- 对
tickets变量的写入操作 发生了并发冲突Thread-A要执行tickets--(这是一个 "读取-修改-写入" 的复合操作)Thread-B也同时要执行tickets--- 这两个操作在微观指令层面交织在一起,导致其中一个线程的写入结果被另一个覆盖(例如,A 刚写完 0,B 紧接着又写入 -1,把 0 覆盖了)
- 对
互斥 和同步就是为解决这些问题而生的两种机制,后面不断的遇到这些问题,对于互斥和同步的认识也会越来越深刻
1. 线程互斥
1.1 问题引入
多个线程协作完成任务时,若仅访问各自栈上的私有数据,则无需考虑同步互斥。然而,更常见的场景是需要访问全局变量 。这种被多个线程共享的资源,称为共享资源 。共享资源中有些数据不能同时访问(最常见的就是同时修改,就会出错),这种共享资源被称为临界资源 。访问临界资源的那段代码则被称为临界区
尽管 "临界资源" 和 "临界区" 本质上是同一概念的不同表述,但侧重点不同:临界资源指明了冲突的对象 ,而临界区则突出了冲突发生的代码范围 。问题就变得清晰起来了:隐患并非来自 "访问资源" 本身,而是来自多个线程同时执行这段临界区代码
所以,解决方案就逐渐清晰了:我们需要一种机制来保护这段代码,确保一次只有一个线程能够执行它。这种机制称为互斥。其具体表现为:一个线程进入临界区时,其他线程无法进入各自的临界区,必须等待当前线程离开。🌰 这类似于一个带锁的房间,一次只允许一人进入,进入后锁门,出来后再让他人进入。互斥机制保证了线程在执行临界区时不会被打断或切换,从而避免了数据不一致的问题
现在把视角聚焦到临界区本身,它正是那段访问共享变量的代码。并发执行会导致数据错误。以以下代码为例:
c++
#include <iostream>
#include <unistd.h>
#include <pthread.h>
int tickets = 1000;
void *BuyTickets(void *args)
{
char *name = (char *)args;
while (true)
{
if (tickets > 0)
{
usleep(1000); // 模拟抢票时间
printf("%s get tickets: %d\n", name, tickets); // 模拟返回票据
tickets--; // 更新票数
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t tid1, tid2, tid3, tid4, tid5;
pthread_create(&tid1, nullptr, BuyTickets, (void *)"Thread-1");
pthread_create(&tid2, nullptr, BuyTickets, (void *)"Thread-2");
pthread_create(&tid3, nullptr, BuyTickets, (void *)"Thread-3");
pthread_create(&tid4, nullptr, BuyTickets, (void *)"Thread-4");
pthread_create(&tid5, nullptr, BuyTickets, (void *)"Thread-5");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
pthread_join(tid4, nullptr);
pthread_join(tid5, nullptr);
return 0;
}
一次运行结果
bash
Thread-1 get tickets: 1000
Thread-2 get tickets: 1000
Thread-4 get tickets: 998
Thread-5 get tickets: 997
Thread-3 get tickets: 1000
......
Thread-2 get tickets: 1
Thread-5 get tickets: 0
Thread-1 get tickets: -1
Thread-4 get tickets: -2
Thread-3 get tickets: -3
其运行结果出现票号重复(多个1000)或为负数,这在真实系统中是致命的。下面我们来看看为什么会出现这样的结果
上述代码中临界区为:
c++
if (tickets > 0) {
usleep(1000);
printf("%s get tickets: %d\n", name, tickets);
tickets--;
} else {
break;
}
问题的关键在于 if (tickets > 0) 和 tickets-- 这两行非原子操作
为何是非原子操作?
原子性是指 操作 作为一个不可分割的整体执行,要么完全执行,要么完全不执行。看似简单的 tickets-- 在底层通常对应多条机器指令:
- LOAD :将
tickets的值从内存载入CPU寄存器 - DEC:在寄存器中对值进行递减计算
- STORE :将计算结果写回
tickets所在的内存地址
反汇编之后的 tickets--
assembly
1216: 8b 05 f4 2d 00 00 mov 0x2df4(%rip),%eax # 4010 <tickets>
121c: 83 e8 01 sub $0x1,%eax
121f: 89 05 eb 2d 00 00 mov %eax,0x2deb(%rip) # 4010 <tickets>
线程在执行完其中任何一条指令后都可能被切换,导致上下文(如寄存器中的中间值)与内存中的实际值产生不一致,下面我们来具体分析一下
为何出现多个1000?
- 线程A执行
LOAD指令将票数1000读入寄存器后,时间片用完被挂起 - 线程B被调度,同样完整地执行了
LOAD->DEC->STORE流程,将票数更新为999 - 但当线程A再次被调度时,它会从被挂起的地方(
DEC或STORE)继续执行,基于它之前读到的旧值(1000)进行操作,最终将1000输出,加上前面线程B输出的1000,就打印出多个1000了,最后将999写回内存,覆盖了线程B的结果,导致票数 "回退",从而被多个线程获取
为何出现负数?
- 出现负数的根源在于
if (tickets > 0)判断的非原子性,以及printf和tickets--操作会重新从内存加载tickets的值 - 当票数仅剩1时,线程C执行
LOAD指令将票数1读入寄存器,进行if (tickets > 0)的判断,随后被挂起 - 此时线程D被调度,它读取到的票数仍是1(大于0),于是执行购票操作,
tickets--后将票数更新为0 - 接着,线程C被重新调度,操作系统恢复线程C的上下文
- 注意:它不会重新执行
if (tickets > 0)这个完整的判断逻辑 ,它已经从之前执行的指令中得到了判断结果(或者PC即将执行的就是判断后的指令) PC指回的是它当初被中断时的下一条指令(这很关键)- 所以现在
PC指向printf或usleep,寄存器值可能包括判断结果 - 但是执行
printf输出的时候,会从内存重新加载tickets的当前值(现在是0),因此输出 "Thread-C get tickets: 0" - 然后执行
tickets--,递减为-1,写回内存
- 注意:它不会重新执行
- 如果还有线程(如线程E)在类似状态下被挂起(已通过判断但未执行
printf),当它恢复时,会从内存加载tickets(可能已是-1),输出 "Thread-E get tickets: -1",并执行tickets--使票数变为-2 - 如果还有其他线程在更早的时候,和上面一样,形成了 "套娃",导致票数变为更低的负数
注意:usleep(1000) 模拟了实际场景中的处理延时(如网络延迟或计算耗时),增加了线程切换的概率,加剧了数据竞争的问题
那该如何解决这个问题呢?前面说了解决方案就是互斥机制,核心是为了保证在同一时刻,只有一个线程可以访问某个共享资源 ,实现互斥的常用工具通常被称为锁(Lock) 或互斥量(Mutex) ,最常用的工具就是互斥量(Mutex) (还有读写锁、自旋锁等),线程在访问共享资源前先加锁(Lock) ,如果锁已被其他线程持有,则当前线程会被阻塞(进入等待队列)。访问完成后解锁(Unlock),唤醒等待的线程
以 Linux 中的(pthread)库中提供的互斥锁(Mutex)为例
c++
#include <iostream>
#include <unistd.h>
#include <pthread.h>
int tickets = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 定义并初始化互斥锁
void *BuyTickets(void *args)
{
char *name = (char *)args;
while (true)
{
pthread_mutex_lock(&lock); // 加锁:进入临界区
if (tickets > 0)
{
usleep(1000);
printf("%s get tickets: %d\n", name, tickets);
tickets--;
pthread_mutex_unlock(&lock); // 解锁:离开临界区 (正常路径)
}
else
{
pthread_mutex_unlock(&lock); // 解锁:离开临界区 (分支路径)!
break;
}
}
return nullptr;
}
// ... (main函数不变)
注意:
- 锁的粒度应尽可能小,因此在离临界区最近的地方加锁
- 必须在所有退出临界区的路径(包括所有分支)上都进行解锁,否则会导致死锁
- 加锁后,执行结果正确,票数从1000顺序递减至1,解决了数据不一致问题
再次运行就能看到 tickets 正常减少到1
bash
Thread-1 get tickets: 1000
Thread-2 get tickets: 999
Thread-2 get tickets: 998
Thread-2 get tickets: 997
Thread-2 get tickets: 996
Thread-2 get tickets: 995
......
Thread-4 get tickets: 3
Thread-4 get tickets: 2
Thread-4 get tickets: 1
概念总结
- 临界资源:一次仅允许一个线程使用的资源,这些资源往往是多线程执行流共享的,是共享资源中需要保护的部分(不是所有的共享资源都需要保护,比如说只进行读取)
- 临界区:每个线程中访问临界资源的那段代码 。注意,临界区是代码段,是程序的一部分
- 原子性:一个或多个操作被视为一个不可分割的整体 。这些操作要么全部执行成功 ,要么完全不执行,在执行过程中不会被任何其他线程中断
- 互斥:一种机制,用于保证当有一个线程在临界区内执行时,其他任何线程都不能进入它们的临界区
- 互斥核心:对共享资源的访问 "包裹" 在一段临界区内,确保每次只有一个线程可以进入临界区
1.2 互斥量(mutex)
前面介绍了互斥的机制,认识了互斥的概念,并且看到了可以通过 pthread_mutex_lock() 的方式加锁保护临界区的代码。下面介绍一下具体的实现和使用
互斥量/互斥锁Mutex ,全称 Mutual Exclusion Lock,直译过来就是相互排斥的锁:就是实现 "互斥" 这个概念的一种具体机制(或工具,其实就是一把锁 🔒) 。pthread_mutex_* :是 Linux (更准确地说是 POSIX 标准)为实现互斥量而提供的一套 API 接口
严格来说,它不是"第三方库" :pthread_mutex_* 系列函数是 POSIX 线程标准 (pthreads) 定义的一部分。在 Linux 上,它们由 glibc 或 musl-libc 等 C 标准库 提供。所以它是 "标准库" 的一部分,而不是像 libcurl 那样的第三方库,但是常常在使用的时候需要加上链接的选项,又不太像一个标准库(现在大多数集成到了 libc.so 中)
1.2.1 互斥量 API
1. 定义和初始化一个 Mutex
有两种初始化方式:
-
静态初始化(推荐,更简单):
使用宏
PTHREAD_MUTEX_INITIALIZER在定义时初始化cpthread_mutex_t my_mutex = PTHREAD_MUTEX_INITIALIZER; -
动态初始化(更灵活):
使用
pthread_mutex_init函数。这种方式允许你设置Mutex的属性(如设置成递归锁等)cpthread_mutex_t my_mutex; int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); // 第二个参数 NULL 表示使用默认属性
2. 加锁和解锁
-
加锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);- 如果
Mutex未被锁定,调用线程会立即获得锁并进入临界区 - 如果
Mutex已被其他线程锁定,调用线程会阻塞(Block),直到锁被释放
- 如果
-
尝试加锁(非阻塞):
int pthread_mutex_trylock(pthread_mutex_t *mutex);- 尝试获取锁,如果锁已被占用,它不会阻塞,而是立即返回一个
EBUSY错误码。适用于"尝试获取,获取不到我就先做别的事" 的场景
- 尝试获取锁,如果锁已被占用,它不会阻塞,而是立即返回一个
-
解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);- 释放锁,让其他被阻塞的线程有机会获得锁
3. 销毁一个 Mutex
对于动态初始化的 Mutex,或者静态初始化但不再需要的 Mutex,应该调用 pthread_mutex_destroy 来释放它可能占用的资源
c
int pthread_mutex_destroy(pthread_mutex_t *mutex);
4. 返回值
成功时,所有函数都返回 0
失败时,它们返回一个非零的错误码 (<errno.h>),并且互斥量的状态保持不变
使用示例:
cpp
// thread.cpp
#include <iostream>
#include <pthread.h>
// #include <unistd.h>
#define BUFFERSIZE 128
int tickets = 10000;
pthread_mutex_t lock;
void *BuyTickets(void *args)
{
int count = 0;
char *buffer = new char[BUFFERSIZE];
char *name = (char*)(args);
// 临界区内加锁
while (true)
{
pthread_mutex_lock(&lock);
if (tickets > 0)
{
printf("%s get ticket: %d\n", name, tickets);
tickets--;
count++;
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
}
snprintf(buffer, BUFFERSIZE, "%s get %d tickets", name, count);
return (void *)buffer;
}
int main()
{
// 初始化锁(动态分配)
pthread_mutex_init(&lock, nullptr);
pthread_t tid1, tid2, tid3;
pthread_create(&tid1, nullptr, BuyTickets, (void *)"Thread-1");
pthread_create(&tid2, nullptr, BuyTickets, (void *)"Thread-2");
pthread_create(&tid3, nullptr, BuyTickets, (void *)"Thread-3");
void *ret1;
void *ret2;
void *ret3;
pthread_join(tid1, &ret1);
printf("%s\n",(char*)ret1);
delete[] (char*)ret1;
pthread_join(tid2, &ret2);
printf("%s\n",(char*)ret2);
delete[] (char*)ret2;
pthread_join(tid3, &ret3);
printf("%s\n",(char*)ret3);
delete[] (char*)ret3;
// 销毁锁
pthread_mutex_destroy(&lock);
return 0;
}
运行
bash
$ ./thread
Thread-2 get ticket: 10000
Thread-2 get ticket: 9999
Thread-2 get ticket: 9998
......
Thread-3 get ticket: 3
Thread-3 get ticket: 2
Thread-3 get ticket: 1
Thread-1 get 3348 tickets
Thread-2 get 3274 tickets
Thread-3 get 3378 tickets
$
1.2.2 互斥量实现原理
到现在,我们已经意识到单纯的 count++ 或者 ++count都不是原子的,互斥量围绕着如何原子地 (即不可被中断地) 检查和修改这个 count 变量
为了实现互斥量的操作,大多数操作系统都提供了 swap 或者 exchange 指令,该指令的作用是将寄存器和内存单元的数据相交换,由于是一条指令,保证了原子性,那么问题就解决了
想象一个共享变量 lock,它只有两个值:
0(解锁状态,表示临界区无人占用)1(加锁状态,表示临界区已被占用)
加锁尝试 (伪代码逻辑):
c
// 假设 atomic_swap(ptr, value) 原子地将 *ptr 的值设置为 value,并返回 *ptr 的旧值
int lock = 0; // 全局锁变量
void my_lock() {
int acquired = 0;
while (!acquired) {
// 关键操作:尝试把 1 塞进 lock,并看看 lock 原来是什么
if (atomic_swap(&lock, 1) == 0) {
// 如果 atomic_swap 返回 0,说明锁之前是空闲的,我们成功拿到了!
acquired = 1;
} else {
// 如果返回 1,说明锁被别人拿着,我们继续循环尝试(忙等待)
}
}
}
解锁 (伪代码逻辑):
c
void my_unlock() {
// 简单地、原子地将锁置回 0
// 注意:这里也需要是原子的,防止在写回的过程中产生混乱
atomic_store(&lock, 0);
}
这就是互斥量最核心、最底层的原理:利用硬件提供的一条原子指令,实现对 "锁状态" 这个单一变量的独占式修改,从而决定哪个线程有权进入临界区
总结
在用户层面使用的 pthread_mutex_lock/unlock,就是标准库利用原子指令(如SWAP),结合操作系统内核的调度能力,做出优化(上面的等待等),进而实现互斥及其调用
1.2.3 互斥量的封装
后面会用到的小组件,先封装一下 📄
cpp
// Lock.hpp
#pragma once
#include <pthread.h>
#include <cstring>
namespace MutexModule
{
// 单独使用/通过LockGuard管理(RAII风格管理)
class Mutex
{
public:
Mutex()
{
int ret = pthread_mutex_init(&_lock, nullptr);
if (ret != 0)
std::cerr << "pthread_mutex_init failed, " << strerror(ret) << std::endl;
}
// 不能被拷贝和赋值
Mutex(const Mutex &) = delete;
Mutex &operator=(const Mutex &) = delete;
// 加锁(阻塞)
bool Lock()
{
return pthread_mutex_lock(&_lock) == 0;
}
// 加锁(非阻塞)
int TryLock()
{
return pthread_mutex_trylock(&_lock);
}
// 解锁
bool Unlock()
{
return pthread_mutex_unlock(&_lock) == 0;
}
bool Destroy()
{
return pthread_mutex_destroy(&_lock) == 0;
}
pthread_mutex_t *GetRawPtr()
{
return &_lock;
}
~Mutex()
{
int ret = pthread_mutex_destroy(&_lock);
if (ret != 0)
std::cerr << "pthread_mutex_destroy failed, " << strerror(ret) << std::endl;
}
private:
pthread_mutex_t _lock;
};
// 管理锁
class LockGuard
{
public:
LockGuard(Mutex &mutex)
: _mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex &_mutex;
};
}
前面的代码就可以写成:
cpp
#include <iostream>
#include <pthread.h>
#include"Lock.hpp"
using namespace MutexModule;
#define BUFFERSIZE 128
int tickets = 10000;
Mutex lock;
void *BuyTickets(void *args)
{
int count = 0;
char *buffer = new char[BUFFERSIZE];
char *name = (char*)(args);
// 临界区内加锁
while (true)
{
// 自动管理锁
LockGuard lock_guard(lock);
// lock.Lock();
if (tickets > 0)
{
printf("%s get ticket: %d\n", name, tickets);
tickets--;
count++;
// lock.Unlock();
}
else
{
// lock.Unlock();
break;
}
}
snprintf(buffer, BUFFERSIZE, "%s get %d tickets", name, count);
return (void *)buffer;
}
int main()
{
pthread_t tid1, tid2, tid3;
pthread_create(&tid1, nullptr, BuyTickets, (void *)"Thread-1");
pthread_create(&tid2, nullptr, BuyTickets, (void *)"Thread-2");
pthread_create(&tid3, nullptr, BuyTickets, (void *)"Thread-3");
void *ret1;
void *ret2;
void *ret3;
pthread_join(tid1, &ret1);
printf("%s\n",(char*)ret1);
delete[] (char*)ret1;
pthread_join(tid2, &ret2);
printf("%s\n",(char*)ret2);
delete[] (char*)ret2;
pthread_join(tid3, &ret3);
printf("%s\n",(char*)ret3);
delete[] (char*)ret3;
return 0;
}
我们也可以不用将锁定义为全局的,把锁当作参数一样传递
cpp
#include <iostream>
#include <pthread.h>
#include <string>
#include "Lock.hpp"
using namespace MutexModule;
#define BUFFERSIZE 128
int tickets = 10000;
// 自定义参数结构体
class ThreadArgs
{
public:
ThreadArgs(Mutex &lock, const char *name)
: _lock(lock), _name(name)
{
}
Mutex &_lock;
std::string _name;
};
void *BuyTickets(void *args)
{
int count = 0;
char *buffer = new char[BUFFERSIZE];
ThreadArgs *arg = static_cast<ThreadArgs *>(args);
// 临界区内加锁
while (true)
{
// 自定义管理锁
LockGuard lock_guard(arg->_lock);
if (tickets > 0)
{
printf("%s get ticket: %d\n", arg->_name.c_str(), tickets);
tickets--;
count++;
}
else
{
break;
}
}
snprintf(buffer, BUFFERSIZE, "%s get %d tickets", arg->_name.c_str(), count);
return static_cast<void *>(buffer);
}
int main()
{
Mutex lock;
ThreadArgs args1(lock, "Thread-1");
ThreadArgs args2(lock, "Thread-2");
ThreadArgs args3(lock, "Thread-3");
pthread_t tid1, tid2, tid3;
pthread_create(&tid1, nullptr, BuyTickets, static_cast<void *>(&args1));
pthread_create(&tid2, nullptr, BuyTickets, static_cast<void *>(&args2));
pthread_create(&tid3, nullptr, BuyTickets, static_cast<void *>(&args3));
void *ret1;
void *ret2;
void *ret3;
pthread_join(tid1, &ret1);
printf("%s\n", (char *)ret1);
delete[] (char *)ret1;
pthread_join(tid2, &ret2);
printf("%s\n", (char *)ret2);
delete[] (char *)ret2;
pthread_join(tid3, &ret3);
printf("%s\n", (char *)ret3);
delete[] (char *)ret3;
return 0;
}
2. 线程同步
2.1 同步概念
它的核心是协调线程间的执行顺序 。互斥解决了 "能不能访问" 的问题,而同步解决了 "什么时候访问" 的问题。它确保某些操作必须在其他操作之后才发生
和互斥部分一样,实现互斥的机制可以使用互斥锁,想要实现同步,可以采用条件变量和信号量
2.2 条件变量
条件变量 是一种线程间通信机制,允许一个线程等待某个条件成立,另一个线程在条件发生变化时发出通知,用来解决线程之间的 等待--通知 协作问题
- 它依赖于 互斥锁(mutex) 来保证检查和修改条件的原子性(注意:它本身并不直接负责互斥)
- 常见于 生产者--消费者模型 、任务队列 、线程池等场景
2.2.1 条件变量 API
1. 初始化条件变量
c
#include <pthread.h>
// 动态初始化
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
// 静态初始化(使用宏)
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
- 参数:
cond: 指向要初始化的条件变量对象的指针attr: 指向条件变量属性对象的指针。通常传入NULL表示使用默认属性
- 返回值: 成功返回
0;失败返回错误编号 - 说明:
pthread_cond_init是动态初始化方式,可以在运行时指定属性(如设置进程间共享等,但通常不需要)PTHREAD_COND_INITIALIZER是静态初始化宏,用于在编译时初始化一个具有默认属性的条件变量。例如:pthread_cond_t my_cond = PTHREAD_COND_INITIALIZER;
2. 销毁条件变量
c
int pthread_cond_destroy(pthread_cond_t *cond);
- 参数:
cond: 指向要销毁的条件变量对象的指针
- 返回值: 成功返回
0;失败返回错误编号 - 说明:
- 用于销毁一个动态初始化的条件变量,释放其可能占用的资源
- 确保没有线程正在等待该条件变量后再进行销毁,否则行为是未定义的
3. 在条件上等待(阻塞)
这是最核心的等待函数
c
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
- 参数:
cond: 指向要在其上等待的条件变量的指针mutex: 指向与条件变量配合使用的互斥锁的指针。在调用此函数前,当前线程必须已经锁定了该互斥锁
- 返回值: 成功返回 0;失败返回错误编号
- 说明:
- 原子性操作: 该函数会原子地 执行以下三个步骤:
- 释放互斥锁
mutex(让其他线程有机会修改条件) - 将当前线程挂起,等待条件变量
cond被唤醒 - 当被唤醒后,在返回之前,重新获取互斥锁
mutex
- 释放互斥锁
- 为什么需要循环检查条件: 线程被唤醒时,条件可能并未真正满足(可能是虚假唤醒/伪唤醒,Spurious Wakeup)。因此,等待条件必须在
while循环中检查谓词(条件表达式)
- 原子性操作: 该函数会原子地 执行以下三个步骤:
4. 带超时的等待
c
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
- 参数:
cond,mutex: 与pthread_cond_wait相同abstime: 指向一个timespec结构体的指针,指定了一个绝对时间 (从1970-01-01 00:00:00 UTC开始的秒和纳秒),表示等待的超时时刻
- 返回值: 成功返回
0;超时返回ETIMEDOUT;失败返回其他错误编号 - 说明:
- 行为与
pthread_cond_wait完全相同,但如果在其参数abstime指定的绝对时间之前条件变量未被触发,函数将返回ETIMEDOUT错误 - 获取绝对时间的方法:可以使用
clock_gettime(CLOCK_REALTIME, &now)获取当前时间,然后加上偏移量来计算
- 行为与
示例: 等待 5 秒
c
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 5; // 设置超时时间为当前时间 + 5 秒
while (condition_is_false) {
int ret = pthread_cond_timedwait(&cond, &mutex, &ts);
if (ret == ETIMEDOUT) {
// 处理超时
break;
}
}
5. 唤醒一个等待线程
c
int pthread_cond_signal(pthread_cond_t *cond);
- 参数:
cond: 指向要触发信号的条件变量的指针
- 返回值: 成功返回
0;失败返回错误编号 - 说明:
- 唤醒至少一个正在等待该条件变量的线程(如果有多个线程在等待,具体唤醒哪个取决于调度策略)
- 通常在条件可能变为真,并且只需要唤醒一个线程来处理时就使用它(例如,在生产者-消费者模型中,生产者放入一个物品,只需唤醒一个消费者)
6. 唤醒所有等待线程
c
int pthread_cond_broadcast(pthread_cond_t *cond);
- 参数:
cond: 指向要触发广播的条件变量的指针
- 返回值: 成功返回
0;失败返回错误编号 - 说明:
- 唤醒所有正在等待该条件变量的线程
- 通常在你认为条件变为真,并且所有等待线程都需要被唤醒来处理时就使用它(例如,多个读线程等待一个资源可用,当资源可用时,可以广播所有读线程)
下面看一个比较简单的例子,比如说让新线程等待,直到主线程将其唤醒
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
int count = 0;
int finish = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void *Thread(void *args)
{
char *name = static_cast<char *>(args);
while (true)
{
// 加锁保护临界区
pthread_mutex_lock(&lock);
// 不退出就让线程等待被唤醒执行
while (!finish)
{
pthread_cond_wait(&cond, &lock);
// 唤醒后有可能finish已经改变
if (!finish)
{
std::cout << name << " count " << count << std::endl;
count++;
}
}
// 说明结束了
pthread_mutex_unlock(&lock);
break;
}
std::cout << name << " finish task!" << std::endl;
delete[] name;
return nullptr;
}
int main()
{
std::vector<pthread_t> pthreads;
for (int i = 0; i < 5; i++)
{
pthread_t tid;
char *args = new char[64];
snprintf(args, 64, "Thread - %d", i);
int ret = pthread_create(&tid, nullptr, Thread, (void *)args);
if (ret == 0)
{
pthreads.push_back(tid);
}
else
{
delete[] args;
}
usleep(1000); // 保证顺序性
}
int cnt = 10;
while (cnt)
{
// 唤醒一个线程
std::cout << "Awaken one threads:" << std::endl;
pthread_cond_signal(&cond);
// 唤醒所有线程
// std::cout << "Awaken all threads:" << std::endl;
// pthread_cond_broadcast(&cond);
cnt--;
// 注意要给线程执行时间
sleep(1);
}
// 通知线程结束了
pthread_mutex_lock(&lock);
finish = 1;
pthread_cond_broadcast(&cond);
pthread_mutex_unlock(&lock);
for (auto tid : pthreads)
{
pthread_join(tid, nullptr);
}
// 销毁所和条件变量
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
return 0;
}
运行结果
bash
$ ./thread
Awaken one threads:
Thread - 0 count 0
Awaken one threads:
Thread - 1 count 1
......
Awaken one threads:
Thread - 4 count 9
Thread - 3 finish task!
Thread - 0 finish task!
Thread - 4 finish task!
Thread - 1 finish task!
Thread - 2 finish task!
$
通过这个简单的示例,并不能展示同步的核心,下面我们以具体的场景看看同步是如何发挥作用的
2.2.2 生产消费模型
生产者-消费者模型是一种经典的并发编程模式,可以用来解决多线程环境下的协作问题。该模型包含两类线程(生产者和消费者)通过一个共享的缓冲区进行数据交换
核心的要素有:
- 三种关系 (互斥与同步机制)
- 生产者-生产者:互斥
- 消费者-消费者:互斥
- 生产者-消费者:互斥与同步
- 两种角色 (线程)
- 生产者
- 消费者
- 一个交易场所 (内存)
- 共享缓冲区

生产者-消费者问题本质上是一个有限缓冲区问题,不可避免地会遇到并发编程中的两个问题:
- 资源竞争:多个进程同时访问共享资源
- 执行顺序协调:进程间需要按照特定顺序执行
整体工作流程

模型的优点:
- 生产过程和消费过程解耦:生产者和消费者不需要直接交互
- 支持忙闲不均:可以应对生产速度和消费速度不匹配的情况
- 提高效率:通过并发执行和缓冲区优化系统性能
2.2.3 基于阻塞队列的生产者消费者模型
block.hpp 的实现展示了基于 POSIX 线程的阻塞队列:
2.2.3.1 阻塞队列实现
cpp
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#define QUEUESIZE 5
template <class T>
class blockQueue {
private:
bool isFull() { return _queue.size() >= _capacity; }
bool isEmpty() { return _queue.empty(); }
public:
blockQueue(size_t queue_size = QUEUESIZE)
: _capacity(queue_size), _p_sleep_num(0), _c_sleep_num(0) {
pthread_cond_init(&_full_cond, nullptr);
pthread_cond_init(&_empty_cond, nullptr);
pthread_mutex_init(&_mutex, nullptr);
}
~blockQueue() {
pthread_cond_destroy(&_full_cond);
pthread_cond_destroy(&_empty_cond);
pthread_mutex_destroy(&_mutex);
}
// 生产者将数据入队列
void Enqueue(T data) {
pthread_mutex_lock(&_mutex);
// 使用 while 循环避免伪唤醒问题
while (isFull()) {
_c_sleep_num++;
// pthread_cond_wait 的三步操作:
// 1. 调用成功时在条件变量上等待,挂起前自动释放锁
// 2. 线程被唤醒时默认在临界区内唤醒
// 3. 被唤醒后需要重新申请锁,申请失败会在锁上阻塞等待
pthread_cond_wait(&_full_cond, &_mutex);
_c_sleep_num--;
}
// 队列有空闲位置,插入数据
_queue.push(data);
// 唤醒等待的消费者
if (_p_sleep_num > 0) {
pthread_cond_signal(&_empty_cond);
std::cout << "Awaken consumer: " << std::endl;
}
pthread_mutex_unlock(&_mutex);
}
// 消费者将数据出队列
T Dequeue() {
pthread_mutex_lock(&_mutex);
while (isEmpty()) {
_p_sleep_num++;
pthread_cond_wait(&_empty_cond, &_mutex);
_p_sleep_num--;
}
// 队列有数据,取出数据
T data = _queue.front();
_queue.pop();
// 唤醒等待的生产者
if (_c_sleep_num > 0) {
pthread_cond_signal(&_full_cond);
std::cout << "Awaken producer: " << std::endl;
}
pthread_mutex_unlock(&_mutex);
return data;
}
private:
std::queue<T> _queue; // 交易场所,同时也是临界资源
size_t _capacity; // 队列容量
pthread_cond_t _full_cond; // 队列满的条件变量
pthread_cond_t _empty_cond; // 队列空的条件变量
pthread_mutex_t _mutex; // 互斥锁,保护所有竞争关系
int _c_sleep_num; // 消费者休眠计数
int _p_sleep_num; // 生产者休眠计数
};
2.2.3.2 基础测试验证
测试程序 main.cc:
cpp
#include <vector>
#include <unistd.h>
#include <stdlib.h>
#include <ctime>
#include "BlockQueue.hpp"
#define THREADSNUMS 10
void *Product(void *args) {
int data = 0;
blockQueue<int> *queue = static_cast<blockQueue<int> *>(args);
while (true) {
std::cout << "Push a data: " << data << std::endl;
queue->Enqueue(data++);
sleep(1); // 模拟慢速生产
}
return nullptr;
}
void *Consume(void *args) {
blockQueue<int> *queue = static_cast<blockQueue<int> *>(args);
while (true) {
int data = queue->Dequeue();
std::cout << "Get a data: " << data << std::endl;
}
return nullptr;
}
int main() {
blockQueue<int>* queue = new blockQueue<int>();
pthread_t producer, consumer;
pthread_create(&producer, nullptr, Product, (void *)queue);
pthread_create(&consumer, nullptr, Consume, (void *)queue);
pthread_join(producer, nullptr);
pthread_join(consumer, nullptr);
delete queue;
return 0;
}
测试结果分析:
场景1:生产者慢,消费者快
bash
Push a data: 0
Awaken consumer:
Get a data: 0
Push a data: 1
Awaken consumer:
Get a data: 1
Push a data: 2
Awaken consumer:
Get a data: 2
特征:生产者生产一个数据,消费者立即被唤醒消费,消费者处于等待状态
场景2:生产者快,消费者慢
bash
Push a data: 0
Push a data: 1
Push a data: 2
Push a data: 3
Push a data: 4
Push a data: 5
Awaken producer:
Get a data: 0
Push a data: 6
特征:生产者填满缓冲区后等待,消费者消费一个数据,生产者立即被唤醒生产
2.2.3.3 任务处理扩展
自定义任务类型 Task.hpp:
cpp
#pragma once
class Task {
public:
Task(int x, int y) : _x(x), _y(y) {}
void Calculation() { _result = _x + _y; }
int Result() { return _result; }
int X() { return _x; }
int Y() { return _y; }
private:
int _x;
int _y;
int _result;
};
任务处理测试:
cpp
void *Product(void *args) {
int x = 1, y = 1;
blockQueue<Task> *queue = static_cast<blockQueue<Task> *>(args);
while (true) {
std::cout << "Push a task: " << x << " + " << y << " = ?" << std::endl;
queue->Enqueue(Task(x++, y++));
}
return nullptr;
}
void *Consume(void *args) {
blockQueue<Task> *queue = static_cast<blockQueue<Task> *>(args);
while (true) {
sleep(1); // 模拟慢速消费
Task t = queue->Dequeue();
t.Calculation();
std::cout << "Get a task: " << t.X() << " + " << t.Y() << " = " << t.Result() << std::endl;
}
return nullptr;
}
函数式任务 Task.hpp:
cpp
#include <iostream>
#include <functional>
#include <unistd.h>
#pragma once
// 函数式任务定义
using task_t = std::function<void()>;
void Download() {
std::cout << "Start downloading......" << std::endl;
usleep(10000);
std::cout << "Download completed" << std::endl;
}
函数任务测试:
cpp
void *Product(void *args) {
blockQueue<task_t> *queue = static_cast<blockQueue<task_t> *>(args);
while (true) {
std::cout << "Push a task: " << std::endl;
queue->Enqueue(Download);
sleep(1);
}
return nullptr;
}
void *Consume(void *args) {
blockQueue<task_t> *queue = static_cast<blockQueue<task_t> *>(args);
while (true) {
task_t t = queue->Dequeue();
std::cout << "Get a task: ";
t(); // 执行任务函数
}
return nullptr;
}
2.2.3.4 多生产多消费场景
多生产多消费场景下,阻塞队列无需任何修改即可直接支持,其核心在于消费者和生产者共用队列中的同一把锁 ,这天然构成了所有必要的互斥条件。无论是生产者与生产者之间、消费者与消费者之间,还是生产者与消费者之间的互斥关系,都通过这把共享锁得到了满足。在实际使用中,主要需要调整的是线程创建和参数传递的方式
以下是多生产多消费场景的简单示例:
cpp
#include <vector>
#include <unistd.h>
#include <stdlib.h>
#include <ctime>
#include <string>
#include "BlockQueue.hpp"
#include "Task.hpp"
#define PRODUCER 10
#define CONSUMER 5
int global_counter = 0;
pthread_mutex_t output_mutex = PTHREAD_MUTEX_INITIALIZER;
void SafePrint(const std::string &msg)
{
pthread_mutex_lock(&output_mutex);
std::cout << msg << std::endl;
pthread_mutex_unlock(&output_mutex);
}
pthread_mutex_t global_counter_mutex = PTHREAD_MUTEX_INITIALIZER;
int SafeGet()
{
pthread_mutex_lock(&global_counter_mutex);
int data = global_counter++;
pthread_mutex_unlock(&global_counter_mutex);
return data;
}
template <class T>
class Args
{
public:
Args(const std::string name, blockQueue<T> *queue) : _name(name), _queue(queue) {}
~Args() {}
std::string &GetName()
{
return _name;
}
blockQueue<T> *GetQueue()
{
return _queue;
}
private:
std::string _name;
blockQueue<T> *_queue;
};
void *Product(void *args)
{
Args<int> *a = static_cast<Args<int> *>(args);
while (true)
{
int data = SafeGet();
a->GetQueue()->Enqueue(data);
SafePrint(a->GetName() + " pushed: " + std::to_string(data));
// usleep(100000);
sleep(2);
}
delete a;
return nullptr;
}
void *Consume(void *args)
{
Args<int> *a = static_cast<Args<int> *>(args);
while (true)
{
sleep(1);
int data = (a->GetQueue())->Dequeue();
std::string out(a->GetName() + " got: " + std::to_string(data));
SafePrint(out);
// usleep(200000);
}
delete a;
return nullptr;
}
int main()
{
blockQueue<int> *queue = new blockQueue<int>();
std::vector<pthread_t> producers;
std::vector<pthread_t> consumers;
std::vector<Args<int> *> args_list;
// 创建生产者
for (int i = 0; i < PRODUCER; i++)
{
pthread_t tid;
Args<int> *args = new Args<int>("PRODUCER T" + std::to_string(i), queue);
args_list.push_back(args);
if (pthread_create(&tid, nullptr, Product, (void *)args) == 0)
{
producers.push_back(tid);
}
else
{
delete args;
}
}
// 创建消费者
for (int i = 0; i < CONSUMER; i++)
{
pthread_t tid;
Args<int> *args = new Args<int>("CONSUMER T" + std::to_string(i), queue);
args_list.push_back(args);
if (pthread_create(&tid, nullptr, Consume, (void *)args) == 0)
{
consumers.push_back(tid);
}
else
{
delete args;
}
}
for (auto tid : producers)
{
pthread_join(tid, nullptr);
}
for (auto tid : consumers)
{
pthread_join(tid, nullptr);
}
for (auto arg : args_list)
{
delete arg;
}
delete queue;
return 0;
}
运行结果:
bash
PRODUCER T0 pushed: 0
PRODUCER T2 pushed: 2
PRODUCER T1 pushed: 1
PRODUCER T3 pushed: 3
PRODUCER T4 pushed: 4
Awaken producer:
CONSUMER T0 got: 0
Awaken producer:
CONSUMER T2 got: 1
PRODUCER T6 pushed: 5
Awaken producer:
Awaken producer:
CONSUMER T1 got: 2
Awaken producer:
PRODUCER T5 pushed: 6
PRODUCER T9 pushed: 8
PRODUCER T7 pushed: 7
CONSUMER T4 got: 4
PRODUCER T8 pushed: 9
CONSUMER T3 got: 3
^C
从运行结果可以看出,多个生产者线程迅速将阻塞队列填满,随后多个消费者线程从队列中获取数据进行处理。在这种多生产多消费的场景下,生产者和消费者的相对速度决定了哪一方需要等待。当生产者速度较慢时,消费者会等待生产者提供数据;当消费者速度较慢时,生产者则会等待消费者消费数据以释放队列空间。这种等待关系与单生产单消费场景下的行为模式是一致的,只是并发规模有所扩大
2.2.3.5 小结
通过上述示例可以看出,生产者-消费者模型的高效性并不主要体现在交易场所本身的操作效率上,也不是说 I/O 数据或单个任务的处理有多么高效。其真正的性能优势体现在:
- 并发执行:生产者和消费者可以并行工作,不需要互相等待
- 流水线处理:数据处理形成流水线,提高整体吞吐量
- 资源利用率:充分利用系统资源,避免线程空闲等待
- 负载均衡:通过缓冲区平衡生产速度和消费速度的差异
关键理解:高效的核心在于 "获取任务" 和 "处理任务" 这两个阶段可以并发执行,生产者准备下一个数据时,消费者可以同时处理当前数据,这种并发性才是模型效率的真正来源。虽然这已经接近生产者-消费者模型的边界,但仍然是该模型不可分割的重要组成部分
2.2.3.6 补充 --- 为什么 pthread_cond_wait 需要互斥锁?
2.2.4 条件变量的封装
后面会用到的小组件,先封装一下 📄
cpp
// Cond.hpp
#pragma once
#include <iostream>
#include <thread>
#include <string.h>
#include "Log.hpp"
namespace CondModule
{
using namespace LogModule;
class Cond
{
public:
Cond()
{
int ret = pthread_cond_init(&_cond, nullptr);
if (ret != 0)
{
LOG(LogLevel::ERROR) << "pthread_cond_init failed: " << strerror(ret);
}
}
~Cond()
{
int ret = pthread_cond_destroy(&_cond);
if (ret != 0)
{
LOG(LogLevel::ERROR) << "pthread_cond_destroy failed: " << strerror(ret);
}
}
// 唤醒在条件变量下等待的一个线程
bool Signal()
{
return pthread_cond_signal(&_cond) == 0;
}
// 唤醒在条件变量上等待的所有线程
bool Broadcast()
{
return pthread_cond_broadcast(&_cond) == 0;
}
// 在条件变量上等待
bool Wait(pthread_mutex_t *mutex)
{
return pthread_cond_wait(&_cond, mutex) == 0;
}
private:
pthread_cond_t _cond;
};
}
前面阻塞队列的实现可以写为:
cpp
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include "Lock.hpp"
#include "Cond.hpp"
using namespace MutexModule;
using namespace CondModule;
#define QUEUESIZE 5
template <class T>
class BlockQueue
{
private:
bool isFull()
{
return _queue.size() >= _capacity;
}
bool isEmpty()
{
return _queue.empty();
}
public:
BlockQueue(size_t queue_size = QUEUESIZE)
: _capacity(queue_size), _p_sleep_num(0), _c_sleep_num(0)
{
}
~BlockQueue()
{
}
// 生产者将数据入队列
void Enqueue(T data)
{
LockGuard lock_gard(_mutex);
{
while (isFull())
{
_c_sleep_num++; // 生产者休眠+1
_full_cond.Wait(_mutex.GetPtr()); // 在满了的条件变量上等待
_c_sleep_num--; // 生产者休眠-1
}
// 到这里说明队列一定有空闲位置
_queue.push(data);
// 队列里肯定会有一个数据(唤醒消费者消费数据)
if (_p_sleep_num > 0)
{
// 唤醒消费者的一种方案,也可以不用这种方式唤醒,直接唤醒
_empty_cond.Signal();
std::cout << "Awaken consumer: " << std::endl;
}
}
}
// 消费者将数据出队列
T Dequeue()
{
T data;
LockGuard lock_gard(_mutex);
{
while (isEmpty())
{
_p_sleep_num++; // 消费者休眠+1
_empty_cond.Wait(_mutex.GetPtr()); // 在空了的条件变量上等待
_p_sleep_num--; // 消费者休眠-1
}
// 到这里说明队列里一定有数据
data = _queue.front();
_queue.pop();
// 队列里一定会产生一个空位置(唤醒生产者生产数据)
if (_c_sleep_num > 0)
{
_full_cond.Signal();
std::cout << "Awaken producer: " << std::endl;
}
}
return data;
}
private:
std::queue<T> _queue; // 是交易场所,本质上就是临界资源
size_t _capacity;
Cond _full_cond;
Cond _empty_cond;
Mutex _mutex; // 共用一把锁,维护了消费者和生产者、消费者和消费者,以及生产者和生产者之间的互斥关系
int _c_sleep_num;
int _p_sleep_num;
};
2.3 POSIX 信号量
2.3.1 System V && POSIX
在介绍 POSIX 信号量之前,先简单介绍一下 System V 和 POSIX
关于 System V
System V ,其全称为 AT&T UNIX System V ,是一个具体的、商业化的 UNIX 操作系统发行版系列 。它起源于 AT&T 贝尔实验室的 UNIX,是 UNIX 历史上两大主流分支之一(另一个是 BSD)。其本质是一个操作系统的具体实现 。System V 的内容包括了一套完整的操作系统组件,其中对后世影响最深远的包括 System V IPC (进程间通信)机制,如信号量、消息队列和共享内存,以及其经典的 System V init 初始化系统 ,该系统使用运行级别和链接脚本管理服务启动。虽然作为独立操作系统版本的 System V 已不常见,但其核心技术,尤其是 IPC 和初始化理念,被许多后来的 UNIX 系统(如 IBM AIX)和 Linux 所继承和兼容
关于 POSIX
POSIX ,全称为 Portable Operating System Interface (可移植操作系统接口),其中的 "X" 表明其对 UNIX API 的传承。其本质是一套由 IEEE 制定的开放标准规范 ,它定义了应用程序与操作系统之间的接口。它的起源是为了解决 1980 年代 UNIX 版本泛滥、彼此互不兼容的问题,目标是确保为一种 POSIX 兼容系统编写的应用程序,能够无需修改或仅需少量修改即可移植到另一种系统上编译运行。POSIX 标准的内容非常广泛,它不关心内核如何实现,只规定开发者能调用的 API ,涵盖了基本的系统调用(如 fork, read, write)、文件与目录操作、进程与线程管理(如 pthreads 线程库)、信号处理,甚至包括标准 Shell 和命令行工具的行为。为了与 System V 等系统竞争,它也定义了自己的一套 POSIX IPC 机制
总而言之,System V 是 UNIX 发展史上的一个重要的具体实现和分支 ,而 POSIX 则是在此基础上抽象和统一出来的开放接口标准 。现代操作系统,如 Linux 和 macOS,都致力于遵循 POSIX 标准 以保证应用程序的可移植性,同时为了兼容性和多样性,也通常会吸收并支持来自 System V 的优秀特性 (如 System V IPC)。因此,我们可以将 System V 视为一个影响了标准的 "前辈",而 POSIX 则是维系整个类 UNIX 生态兼容互通的 "通用规范"。在新的开发中,应优先采用 POSIX 接口以保障可移植性
今天我们的重点式信号量,在进程间通信部分提到过,"信号量是一种操作系统提供的同步原语",当时没有过多的展开,现在就可以更深刻的理解什么是信号量,同步在前面已经介绍过了,"原语" 在计算机科学中,它指的是一系列由系统提供的、在执行过程中不可中断的、原子性的基本操作
现在再来理解信号量,信号量 本质上是一个计数器,它用于管理多个进程(或线程)对共享资源的访问,以确保它们不会同时访问该资源,从而避免数据不一致或其他问题
System V 信号量 vs POSIX 信号量对比
| 维度 | System V 信号量 |
POSIX 信号量 |
|---|---|---|
| 设计与复杂度 | 重量级 。它是内核持久对象,功能复杂,API 设计略显陈旧 |
轻量级。设计简洁,旨在提供一套核心的同步功能 |
| 主要应用场景 | 主要用于进程间同步 。因其是内核持久对象,不同进程可以轻松通过 key 找到并访问 |
线程同步的首选 。也可用于进程间同步(需放在共享内存中或使用具名信号量) |
IPC 对象标识 |
使用 key_t(通常由 ftok() 生成)和一个整型的信号量集 ID |
有名信号量 :使用一个路径名 (如 /my_sem) 无名信号量 :一个 sem_t 变量,通常位于共享内存中 |
| 操作函数 | semop():进行 P/V 操作 semctl():控制操作(初始化、删除等) |
sem_wait():P 操作 sem_post():V 操作 sem_init() / sem_open():初始化/创建 |
| 信号量集 | 支持 。一个信号量 ID 可以管理一组信号量,semop() 可以原子地操作集合中的多个信号量 |
不支持。每个信号量都是独立的 |
| 持久性 | 内核持久。即使没有进程使用它,它也会一直存在于内核中,直到被显式删除或系统重启 | 有名信号量 :内核持久 无名信号量:随其所在内存的生命周期(进程终止或共享内存被销毁则消失) |
| 初始化 | 初始化(设置初始值)是一个独立的操作(通过 semctl() 的 SETVAL 或 SETALL 命令),存在竞态条件风险,需要小心处理 |
初始化(sem_init 或 sem_open)和设置初始值是原子操作,创建时即完成,无需担心竞态条件 |
| API 易用性 | 复杂且容易出错 。需要多个步骤(ftok, semget, semctl, semop),并且需要程序员手动处理初始化竞态问题 |
简洁直观 。函数名清晰(wait, post),初始化简单安全 |
| 跨平台性 | 在所有传统的 UNIX 系统和 Linux 上都可用,但在非 UNIX 系统(如 Windows)上支持很差 |
在大多数现代类 UNIX 系统(Linux, macOS, BSD)和通过特定库的 Windows 上可用,可移植性更好 |
| 性能 | 由于是内核持久对象,每次操作都需要进行系统调用,上下文切换开销较大 | 理论上,在某些实现中(如 Linux),无名信号量可能被优化,如果用于线程同步且未涉及进程竞争,可能无需陷入内核,开销更小 |
2.3.2 信号量 API (POSIX 版本)
POSIX 信号量 是一种用于同步和互斥的计数器,用于控制多个线程或进程对共享资源的访问。分为两种类型:
- 无名信号量:用于线程间或相关进程间的同步,基于内存
- 有名信号量:通过名字标识,可用于不相关进程间的同步
注意 :"无名"的真实含义
"无名" 并不是指信号量没有标识符或完全匿名,而是指:
-
没有文件系统路径名
- 有名信号量:通过文件系统路径名标识(如
"/my_sem") - 无名信号量:没有这样的文件系统名字
- 有名信号量:通过文件系统路径名标识(如
-
基于内存的标识
- 无名信号量通过内存地址来标识,而不是通过全局名字
- 无名信号存在于进程的地址空间中,通过指针来访问
-
生命周期绑定
- 有名信号量:独立于创建它的进程,内核持久
- 无名信号量:生命周期与创建它的内存区域绑定
c
// 无名信号量的本质
sem_t sem; // 这是一个具体的对象,在内存中有地址
sem_init(&sem, 0, 1); // 通过内存地址(&sem)来初始化
// 有名信号量的本质
sem_t *sem_ptr; // 这是一个指针
sem_ptr = sem_open("/mysem", O_CREAT, 0644, 1); // 通过路径名获取
1. 初始化无名信号量
c
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
- 参数:
sem: 指向要初始化的信号量对象的指针pshared: 指示信号量是线程间共享还是进程间共享。0表示线程间共享,非0表示进程间共享(此时信号量必须位于共享内存中)value: 信号量的初始值
- 返回值: 成功返回
0;失败返回-1并设置errno - 注意:
- 无名信号量在内存中分配,由程序管理其生命周期
- 用于线程 时,通常放在全局变量 或堆 上;用于进程 时,需放在共享内存中
2. 销毁无名信号量
c
int sem_destroy(sem_t *sem);
- 参数:
sem: 指向要销毁的信号量对象的指针
- 返回值: 成功返回
0;失败返回-1并设置errno - 说明:
- 销毁一个无名信号量,释放可能占用的资源
- 确保没有线程或进程在等待该信号量后再销毁
3. 打开/创建有名信号量
c
sem_t *sem_open(const char *name, int oflag, ... /* mode_t mode, unsigned int value */);
- 参数:
name: 信号量的名字,通常以斜杠开头,例如"/my_sem"oflag: 标志位,可以是O_CREAT(如果不存在则创建)或O_EXCL(与O_CREAT连用,如果已存在则失败)mode: 权限模式(当oflag包含O_CREAT时需要),指定信号量的访问权限(如0644)value: 信号量的初始值(当oflag包含O_CREAT时需要)
- 返回值: 成功返回指向信号量的指针;失败返回
SEM_FAILED并设置errno - 说明:
- 有名信号量通过名字来创建或打开,可用于不相关进程间的同步
- 命名规则依赖于系统,通常出现在文件系统中(如
/dev/shm)
4. 关闭有名信号量
c
int sem_close(sem_t *sem);
- 参数:
sem: 指向要关闭的信号量对象的指针
- 返回值: 成功返回
0;失败返回-1并设置errno - 说明:
- 关闭有名信号量,释放进程内与信号量相关的资源
- 但信号量本身在内核中仍然存在,除非被显式删除
5. 删除有名信号量
c
int sem_unlink(const char *name);
- 参数:
name: 要删除的命名信号量的名字
- 返回值: 成功返回
0;失败返回-1并设置errno - 说明:
- 删除一个有名信号量
- 当所有打开该信号量的进程都关闭它之后,信号量将被内核销毁
6. 等待信号量(P操作)
c
int sem_wait(sem_t *sem);
- 参数:
sem: 指向信号量对象的指针
- 返回值: 成功返回
0;失败返回-1并设置errno - 说明:
- 对信号量进行减一操作。如果信号量的值大于0,则立即返回;如果信号量的值为0,则阻塞直到信号量值大于0(然后减一)或被信号中断
- 这是主要的"获取"操作,也称为
P操作或down操作
7. 非阻塞等待信号量
c
int sem_trywait(sem_t *sem);
- 参数:
sem: 指向信号量对象的指针
- 返回值: 成功返回
0;失败返回-1并设置errno(如果信号量为0,则设置EAGAIN) - 说明:
- 非阻塞版本的
sem_wait - 如果信号量的值大于0,则减一并返回0;如果信号量的值为0,则立即返回失败,并设置
errno为EAGAIN
- 非阻塞版本的
8. 带超时的等待信号量
c
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
- 参数:
sem: 指向信号量对象的指针abs_timeout: 指向一个timespec结构体的指针,指定了一个绝对超时时间
- 返回值: 成功返回
0;失败返回-1并设置errno(超时设置为ETIMEDOUT) - 说明:
- 与
sem_wait类似,但如果在其参数abs_timeout指定的绝对时间之前信号量未能减一,则返回超时错误
- 与
9. 发布信号量(V操作)
c
int sem_post(sem_t *sem);
- 参数:
sem: 指向信号量对象的指针
- 返回值: 成功返回
0;失败返回-1并设置errno - 说明:
- 对信号量进行加一操作。如果有线程或进程正在等待该信号量,则唤醒其中一个
- 这是主要的"释放"操作,也称为
V操作或up操作
10. 获取当前信号量的值
c
int sem_getvalue(sem_t *sem, int *sval);
- 参数:
sem: 指向信号量对象的指针sval: 指向一个整型的指针,用于存储获取的信号量值
- 返回值: 成功返回
0;失败返回-1并设置errno - 说明:
- 获取信号量的当前值。注意,由于其他线程可能同时修改信号量,这个值可能立即过时
- 因此,它通常仅用于调试
使用示例:
无名信号量(线程间同步)
c
#include <semaphore.h>
#include <pthread.h>
#include <stdio.h>
sem_t sem;
int count = 0;
void* thread_func(void* arg) {
sem_wait(&sem); // P操作
count++;
printf("Count: %d\n", count);
sem_post(&sem); // V操作
return NULL;
}
int main() {
pthread_t t1, t2;
sem_init(&sem, 0, 1); // 初始值为1,二进制信号量,用作互斥锁
pthread_create(&t1, NULL, thread_func, NULL);
pthread_create(&t2, NULL, thread_func, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
sem_destroy(&sem);
return 0;
}
有名信号量(进程间同步)
进程1(创建并初始化信号量):
c
#include <fcntl.h>
#include <semaphore.h>
#include <stdio.h>
int main() {
sem_t *sem = sem_open("/my_sem", O_CREAT, 0644, 1);
if (sem == SEM_FAILED) {
perror("sem_open");
return -1;
}
sem_wait(sem);
printf("Process 1 in critical section\n");
// ... 临界区代码 ...
sem_post(sem);
sem_close(sem);
// 注意:这里没有sem_unlink,所以信号量会保留在系统中
return 0;
}
进程2(打开已存在的信号量):
c
#include <fcntl.h>
#include <semaphore.h>
#include <stdio.h>
int main() {
sem_t *sem = sem_open("/my_sem", O_RDWR);
if (sem == SEM_FAILED) {
perror("sem_open");
return -1;
}
sem_wait(sem);
printf("Process 2 in critical section\n");
// ... 临界区代码 ...
sem_post(sem);
sem_close(sem);
sem_unlink("/my_sem"); // 删除信号量
return 0;
}
注意: 使用有名信号量时,要注意及时删除不再使用的信号量,以避免内核资源泄露
下面我们以生产者消费者模型来演示信号量(重点在无名信号量,线程间同步)的使用
2.3.3 基于环形队列的生产者消费者模型
环形队列,也叫循环队列,是队列的一种实现方式 。它使用一个固定大小的数组 和两个指针(队头 Front 和队尾 Rear ),并假想这个数组的首尾是相连的,形成一个逻辑上的 "环"

如何判断队列是"空"还是"满"?是环形队列设计的经典问题。如果 head 和 tail 指向同一个位置,这既可能是队列空 的状态,也可能是队列满的状态
解决方案有两种:
-
牺牲一个存储单元(最常用)
- 约定 :当
(tail + 1) % 数组长度 == head时,认为队列已满 - 这意味着数组中始终有一个位置是不存放数据的,用来区分空和满
- 判空 :
head == tail - 判满 :
(tail + 1) % size == head - 元素个数 :
(tail - head + size) % size
- 约定 :当
-
维护一个计数器
count- 入队时
count++,出队时count-- - 判空 :
count == 0 - 判满 :
count == 数组长度
- 入队时
在生产消费模型中,我们不用将重点放在如何判断队列为空或者为满上,因为信号量的 PV 操作保证了:
- 队列永远不会上溢:生产者只能在有空位时插入
- 队列永远不会下溢:消费者只能在有数据时取出
- 操作永远有序:先预定资源,再执行操作
需要注意下图中的几点:

2.3.3.1 环型队列实现
cpp
// RingQueue.hpp
#pragma once
#include <semaphore.h>
#include <pthread.h>
#include <vector>
#define RINGQUEUESIZE 10
template <class T>
class RingQueue
{
public:
RingQueue(int cap = RINGQUEUESIZE)
: _ring_queue(cap), _cap(cap), _pindex(0), _cindex(0)
{
sem_init(&_blank_sem, 0, cap);
sem_init(&_data_sem, 0, 0);
pthread_mutex_init(&_pmutex, nullptr);
pthread_mutex_init(&_cmutex, nullptr);
}
~RingQueue()
{
sem_destroy(&_blank_sem);
sem_destroy(&_data_sem);
pthread_mutex_destroy(&_pmutex);
pthread_mutex_destroy(&_cmutex);
}
// 生产者入数据
void Enqueue(T in)
{
// 在空位置申请信号量
sem_wait(&_blank_sem); // 本质是资源的预定
pthread_mutex_lock(&_pmutex); // 可以在预定资源前加锁,也可以在预定资源后加锁
// 注意:如果先让线程在预定资源前加锁,效率比在预定资源后加锁高得多
_ring_queue[_pindex] = in; // 1. 插入数据
_pindex++; // 2. 更新下标位置
_pindex %= _cap; // 3. 保持环型结构
pthread_mutex_unlock(&_pmutex);
sem_post(&_data_sem);
}
// 消费者出数据
void Dequeue(T *out)
{
// 在有数据的位置申请信号量
sem_wait(&_data_sem);
pthread_mutex_lock(&_cmutex);
*out = _ring_queue[_cindex]; // 1. 取出数据
_cindex++; // 2. 更新下标位置
_cindex %= _cap; // 3. 保持环型结构
pthread_mutex_unlock(&_cmutex);
sem_post(&_blank_sem);
}
private:
std::vector<T> _ring_queue; // 环型队列底层数据结构采用vector
int _cap; // 固定队列的大小
sem_t _blank_sem; // 生产者生产
sem_t _data_sem; // 消费者消费
int _pindex; // 生产位置下标
int _cindex; // 消费位置下标
// 多生产者多消费者互斥关系所需要的互斥锁
pthread_mutex_t _pmutex;
pthread_mutex_t _cmutex;
};
2.3.3.2 单生产单消费场景
main.cc:
cpp
#include <iostream>
#include <vector>
#include <thread>
#include <unistd.h>
#include <mutex>
#include "RingQueue.hpp"
// 生产者
void *Produce(void *args)
{
int data = 0;
RingQueue<int> *ring_queue = static_cast<RingQueue<int> *>(args);
while (true)
{
// usleep(500000);
// sleep(1);
ring_queue->Enqueue(data);
std::cout << "Producer push a data: " << data << std::endl;
data++;
}
}
// 消费者
void *Consume(void *args)
{
RingQueue<int> *ring_queue = static_cast<RingQueue<int> *>(args);
while (true)
{
sleep(1);
int data;
ring_queue->Dequeue(&data);
std::cout << "Consumer get a data: " << data << std::endl;
}
}
int main()
{
RingQueue<int> ring_queue;
pthread_t producer, consumer;
pthread_create(&producer, nullptr, Produce, static_cast<void *>(&ring_queue));
pthread_create(&consumer, nullptr, Consume, static_cast<void *>(&ring_queue));
pthread_join(producer, nullptr);
pthread_join(consumer, nullptr);
return 0;
}
运行结果:
bash
Producer push a data: 0
Producer push a data: 1
Producer push a data: 2
Producer push a data: 3
Producer push a data: 4
Producer push a data: 5
Producer push a data: 6
Producer push a data: 7
Producer push a data: 8
Producer push a data: 9
Consumer get a data: 0
Producer push a data: 10
Consumer get a data: 1
Producer push a data: 11
Consumer get a data: 2
Producer push a data: 12
Consumer get a data: 3
Producer push a data: 13
Consumer get a data: 4
Producer push a data: 14
^C
2.3.3.3 多生产多消费场景
main.cc:
cpp
#define PRODUCER 2
#define CONSUMER 3
int global_counter = 0;
pthread_mutex_t output_mutex = PTHREAD_MUTEX_INITIALIZER;
void SafePrint(const std::string &msg)
{
pthread_mutex_lock(&output_mutex);
std::cout << msg << std::endl;
pthread_mutex_unlock(&output_mutex);
}
pthread_mutex_t global_counter_mutex = PTHREAD_MUTEX_INITIALIZER;
int SafeGet()
{
pthread_mutex_lock(&global_counter_mutex);
int data = global_counter++;
pthread_mutex_unlock(&global_counter_mutex);
return data;
}
template <class T>
class Args
{
public:
Args(const std::string name, RingQueue<T> *queue) : _name(name), _queue(queue) {}
~Args() {}
std::string &GetName()
{
return _name;
}
RingQueue<T> *GetQueue()
{
return _queue;
}
private:
std::string _name;
RingQueue<T> *_queue;
};
void *Product(void *args)
{
Args<int> *a = static_cast<Args<int> *>(args);
while (true)
{
sleep(1);
int data = SafeGet();
a->GetQueue()->Enqueue(data);
SafePrint(a->GetName() + " pushed: " + std::to_string(data));
// std::cout << a->GetName() + " pushed: " + std::to_string(data) << std::endl;
// usleep(100000);
}
delete a;
return nullptr;
}
void *Consume(void *args)
{
Args<int> *a = static_cast<Args<int> *>(args);
while (true)
{
// sleep(1);
int data;
a->GetQueue()->Dequeue(&data);
std::string out(a->GetName() + " got: " + std::to_string(data));
SafePrint(out);
// std::cout << a->GetName() + " got: " + std::to_string(data) << std::endl;
// usleep(200000);
}
delete a;
return nullptr;
}
int main()
{
RingQueue<int> *queue = new RingQueue<int>();
std::vector<pthread_t> producers;
std::vector<pthread_t> consumers;
std::vector<Args<int> *> args_list;
// 创建生产者
for (int i = 0; i < PRODUCER; i++)
{
pthread_t tid;
Args<int> *args = new Args<int>("PRODUCER T" + std::to_string(i), queue);
args_list.push_back(args);
if (pthread_create(&tid, nullptr, Product, (void *)args) == 0)
{
producers.push_back(tid);
}
else
{
delete args;
}
}
// 创建消费者
for (int i = 0; i < CONSUMER; i++)
{
pthread_t tid;
Args<int> *args = new Args<int>("CONSUMER T" + std::to_string(i), queue);
args_list.push_back(args);
if (pthread_create(&tid, nullptr, Consume, (void *)args) == 0)
{
consumers.push_back(tid);
}
else
{
delete args;
}
}
for (auto tid : producers)
{
pthread_join(tid, nullptr);
}
for (auto tid : consumers)
{
pthread_join(tid, nullptr);
}
for (auto arg : args_list)
{
delete arg;
}
delete queue;
return 0;
}
运行结果:
bash
PRODUCER T1 pushed: 1
CONSUMER T1 got: 1
PRODUCER T0 pushed: 0
CONSUMER T0 got: 0
PRODUCER T1 pushed: 2
CONSUMER T2 got: 2
CONSUMER T1 got: 3
PRODUCER T0 pushed: 3
PRODUCER T1 pushed: 4
CONSUMER T0 got: 4
PRODUCER T0 pushed: 5
CONSUMER T1 got: 5
PRODUCER T1 pushed: 6
CONSUMER T2 got: 6
CONSUMER T1 got: 7
PRODUCER T0 pushed: 7
PRODUCER T1 pushed: 8
CONSUMER T0 got: 8
CONSUMER T0 got: 9
PRODUCER T0 pushed: 9
PRODUCER T1 pushed: 10
CONSUMER T1 got: 10
PRODUCER T0 pushed: 11
CONSUMER T0 got: 11
^C
2.3.3.4 小结
基于环形队列的生产者消费者模型与基于阻塞队列的模型在同步机制上有所不同。环形队列使用信号量 来管理资源的可用性,通过两个信号量分别跟踪空位置 和有数据位置 的数量。这样可以让生产者和消费者在满足资源条件时并发执行,提高了系统的吞吐量
在单生产单消费场景中,由于只有一个生产者和一个消费者,不需要额外的互斥锁保护,因为信号量已经提供了必要的同步 。而在多生产多消费场景中,需要为生产者和消费者分别提供互斥锁,以确保对环形队列索引的原子性更新
环形队列的实现利用了模运算来维持循环结构,使得队列在逻辑上呈现环形。生产者和消费者分别维护自己的索引指针,通过信号量的 P/V 操作来协调彼此的执行节奏
2.3.4 信号量的封装
后面会用到的小组件,先封装一下 📄
cpp
// Sem.hpp
#pragma once
#include <iostream>
#include <string.h>
#include <semaphore.h>
#define SEMSIZE 1
namespace SemModule
{
class Sem
{
public:
Sem(unsigned int cap = SEMSIZE)
{
int ret = sem_init(&_sem, 0, cap);
if (ret != 0)
{
std::cerr << "pthread_cond_init failed: " << strerror(ret) << std::endl;
}
}
~Sem()
{
int ret = sem_destroy(&_sem);
if (ret != 0)
{
std::cerr << "pthread_cond_init failed: " << strerror(ret) << std::endl;
}
}
// 申请资源,如果信号量值大于0就--并继续;如果等于0,则进程阻塞等待
void P()
{
int ret = sem_wait(&_sem);
if (ret != 0)
{
std::cerr << "pthread_cond_init failed: " << strerror(ret) << std::endl;
}
}
// 释放资源,并且将信号量++,并且唤醒等待该信号量的进程
void V()
{
int ret = sem_post(&_sem);
if (ret != 0)
{
std::cerr << "pthread_cond_init failed: " << strerror(ret) << std::endl;
}
}
private:
sem_t _sem;
};
}
环型队列的实现可以写成这样:
cpp
using namespace SemModule;
using namespace MutexModule;
#define RINGQUEUESIZE 10
template <class T>
class RingQueue
{
public:
RingQueue(int cap = RINGQUEUESIZE)
: _ring_queue(cap), _cap(cap), _blank_sem(cap), _data_sem(0), _pindex(0), _cindex(0)
{
}
~RingQueue()
{
}
// 生产者入数据
void Enqueue(T in)
{
// 在空位置申请信号量
_blank_sem.P();
{
LockGuard lock_guard(_pmutex);
_ring_queue[_pindex] = in; // 1. 插入数据
_pindex++; // 2. 更新下标位置
_pindex %= _cap; // 3. 保持环型结构
}
_data_sem.V();
}
// 消费者出数据
void Dequeue(T *out)
{
// 在有数据的位置申请信号量
_data_sem.P();
{
LockGuard lock_guard(_cmutex);
*out = _ring_queue[_cindex]; // 1. 取出数据
_cindex++; // 2. 更新下标位置
_cindex %= _cap; // 3. 保持环型结构
}
_blank_sem.V();
}
private:
std::vector<T> _ring_queue; // 环型队列底层数据结构采用vector
int _cap; // 固定队列的大小
Sem _blank_sem; // 生产者生产
Sem _data_sem; // 消费者消费
int _pindex; // 生产位置下标
int _cindex; // 消费位置下标
// 多生产者多消费者互斥关系所需要的互斥锁
Mutex _pmutex;
Mutex _cmutex;
};
3. 总结
3.1 多线程使用资源的两种核心场景
3.1.1 整体资源使用模式
- 使用工具 :互斥锁(
mutex)或二元信号量 - 对应模型:基于阻塞队列的生产者消费者模型
- 特点:将整个共享资源作为一个整体进行保护,同一时刻只允许一个线程访问整个资源
- 适用场景:资源无法分割或需要原子性整体操作的场景
3.1.2 分块资源使用模式
- 使用工具:计数信号量
- 对应模型:基于环形队列的生产者消费者模型
- 特点:将资源划分为多个独立的 "块",允许多个线程同时访问不同的资源块
- 适用场景:资源可以逻辑分割且需要更高并发度的场景
3.2 信号量的本质理解
3.2.1 第一层本质:资源计数器与预定机制
信号量本质上是一个计数器,实现了对资源的预定机制:
- P操作:申请资源,计数器减1,若无资源则阻塞等待
- V操作:释放资源,计数器加1,唤醒等待线程
- 预定特性:线程在访问实际资源前必须先成功预定
3.2.2 第二层本质:前置条件判断的原子化
信号量将临界资源状态的判断以原子操作的形式前置:
- 传统方式:先访问共享变量判断条件,再决定是否等待
- 信号量方式 :直接通过
P操作原子性地完成条件判断和资源预定 - 优势:避免了 "检查后行动" 的竞态条件
3.2.3 第三层本质:信号量自身的临界性
所有线程都需要访问信号量,信号量本身的计数器操作也是临界操作:
- 信号量的
PV操作必须是原子的 - 信号量实现内部也需要同步机制
- 这就解释了为什么信号量能够安全地协调多个线程
3.3 两种模型的对比
阻塞队列模型(整体保护):
cpp
// 单一互斥锁保护整个队列
pthread_mutex_lock(&_mutex);
while (isFull()) {
pthread_cond_wait(&_full_cond, &_mutex);
}
// 操作队列...
pthread_mutex_unlock(&_mutex);
环形队列模型(分块保护):
cpp
// 信号量分块保护
_blank_sem.P(); // 预定空位资源
{
LockGuard lock_guard(_pmutex);
// 操作特定位置...
}
_data_sem.V(); // 释放数据资源
3.4 并发控制的两种基本思路
-
悲观并发控制(阻塞队列)
- 假设冲突经常发生
- 通过互斥锁避免冲突
- 简单可靠,但并发度有限
-
乐观并发控制(环形队列)
- 假设冲突较少发生
- 通过资源划分减少冲突
- 并发度高,但实现复杂
4. 线程池
前面封装过的小组件,下面会用到。在实现线程池之前,我们先来实现一个简单的日志,以便于在线程池运行中观察运行信息
4.1 日志模块
cpp
// Log.hpp
#pragma once
#include <iostream>
#include <string>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <memory>
#include <unistd.h>
#include <time.h>
#include <ctime>
#include "Lock.hpp" // 引入互斥量的封装
#define Separator "\r\n"
namespace LogModule
{
using namespace MutexModule;
// 输出到文件的默认文件名和路径
std::string default_file_name = "ThreadPool.log";
std::string default_file_path = "./log";
// 日志等级
enum class LogLevel
{
DEBUG, // 调试使用
INFO, // 正常信息
WARNING, // 警告信息
ERROR, // 错误信息
FATAL, // 灾难性错误
};
// 枚举类型转换字符串-方便形成日志
std::string EnumToString(LogLevel type)
{
switch (type)
{
case LogLevel::DEBUG:
return "DEBUG";
break;
case LogLevel::INFO:
return "INFO";
break;
case LogLevel::WARNING:
return "WARNING";
break;
case LogLevel::ERROR:
return "ERROR";
break;
case LogLevel::FATAL:
return "FATAL";
break;
default:
return "UNKNOWN"; // 未知错误
break;
}
}
// 日志策略:1.输出到显示器 2.输出到文件 ......
class LogStrategy // 策略子类
{
public:
virtual void RefreshStrategy(std::string message) = 0; // 纯虚函数-必须重写
virtual ~LogStrategy() = default; // 必须让派生类调用自己的析构释放资源
};
// 1. 输出到显示器-控制台策略派生类
class ConsoleStrategy : public LogStrategy
{
public:
ConsoleStrategy() {}
void RefreshStrategy(std::string message) override
{
LockGuard lock_gard(_lock); // 加锁-处理竟态条件
std::cout << message << Separator;
}
~ConsoleStrategy() {}
private:
Mutex _lock;
};
// 2. 输出到文件-文件策略派生类
class FileStrategy : public LogStrategy
{
public:
FileStrategy(std::string name = default_file_name, std::string path = default_file_path)
: _file_name(name),
_file_path(path)
{
// 检查路径是否存在
if (!std::filesystem::exists(_file_path))
{
// 创建路径
try
{
std::filesystem::create_directory(_file_path);
std::cout << "create_directory success!" << std::endl;
}
catch (const std::filesystem::filesystem_error &em)
{
std::cerr << "filesystem error: " << em.what() << std::endl;
}
}
}
void RefreshStrategy(std::string message) override
{
// 加锁-处理竟态条件
LockGuard lock_gard(_lock);
{
// 写入文件
std::string file = _file_path + (_file_path.back() == '/' ? "" : "/") + _file_name; // ./ThreadPool.log
std::ofstream in(file, std::ios::app); // 以追加方式打开文件
if (!in.is_open())
return;
in << message << Separator;
in.close();
}
}
~FileStrategy() {}
private:
std::string _file_name;
std::string _file_path;
Mutex _lock;
};
// 获取当前时间
std::string GetCurrentTime()
{
time_t current_time = time(nullptr);
struct tm time_info;
struct tm *result = localtime_r(¤t_time, &time_info);
char time_buffer[128];
snprintf(time_buffer, sizeof(time_buffer) / sizeof(time_buffer[0]),
"%04d-%02d-%02d %02d:%02d:%02d",
time_info.tm_year + 1900, // 年份从1900年开始
time_info.tm_mon + 1, // 月份只有11个月
time_info.tm_mday,
time_info.tm_hour,
time_info.tm_min,
time_info.tm_sec);
return time_buffer;
}
// 真正的日志类
class Logger
{
public:
Logger()
{
EnableConsoleStrategy(); // 默认采用控制台策略,避免_strategy_ptr悬空
}
void EnableConsoleStrategy()
{
_strategy_ptr = std::make_unique<ConsoleStrategy>();
}
void EnableFileStrategy()
{
_strategy_ptr = std::make_unique<FileStrategy>();
}
// 内部类-专门用于形成一条完整的日志
// [year-month-day hour:minute:seconds] [DEBUG] [pid] [main.cc] [line number] - information
class Log
{
public:
Log(LogLevel &log_level, std::string &file_name, int line_number, Logger &logger)
: _current_time(GetCurrentTime()),
_log_level(EnumToString(log_level)),
_process_id(getpid()),
_file_name(file_name),
_line_number(line_number),
_logger(logger)
{
LogMessage();
}
// 格式化日志内容->左半部分(固定内容-不用外部输入)
void LogMessage()
{
// 不涉及临界资源得访问或修改-不用加锁
std::stringstream ss;
ss << "[" << _current_time << "] "
<< "[" << _log_level << "] "
<< "[" << _file_name << "] "
<< "[" << _line_number << "] - ";
_complete_log_messages += ss.str();
}
// Log(LogLevel::Debug, "main.cc", 12) << "Hello " << "log" << 3.14 << "|||"
// 模板+返回原生类型处理多参数日志内容
template <class T>
Log &operator<<(const T &t)
{
// 避免调用同一个日志对象输出导致的竟态条件
LockGuard lock_grad(_lock);
{
// 将不同数据类型统一放入到字符串流中处理
std::stringstream ss;
ss << t;
_complete_log_messages += ss.str();
return *this;
}
}
// 将一条完整的日志按照指定策略刷新出
~Log()
{
// 析构时按策略刷新日志内容
if (_logger._strategy_ptr)
{
_logger._strategy_ptr->RefreshStrategy(_complete_log_messages);
}
}
private:
std::string _complete_log_messages; // 完整的一条日志信息
std::string _current_time; // 当前时间
std::string _log_level; // 日志等级
pid_t _process_id; // 进程ID
std::string _file_name; // 文件名
int _line_number; // 行号
Logger &_logger; // 外部类的引用,如果用对象会导致死循环(计算大小的相互依赖关系)
Mutex _lock; // 互斥锁
};
// Logger logger(LogLevel::DEBUG,"main.cc",12) << "Hello log!";
// 写作临时对象,返回即刷新
Log operator()(LogLevel level, std::string name, int line)
{
// 不涉及临界资源得访问或修改-不用加锁
return Log(level, name, line, *this);
}
~Logger() {}
private:
std::unique_ptr<LogStrategy> _strategy_ptr; // 刷新策略
Mutex _lock; // 互斥锁
};
// 通过唯一的全局对象调用(也可以在外部单独创建对象使用)-简化操作
Logger logger;
#define LOG(level) logger(level, __FILE__, __LINE__)
#define ENABLE_FILE_STRATEGY() logger.EnableFileStrategy()
#define ENABLE_CONSOLE_STRATEGY logger.EnableConsoleStrategy()
}
4.2 线程池
cpp
// ThreadPool.hpp
#pragma once
#include <string>
#include <vector>
#include <queue>
#include "Log.hpp"
#include "Thread.hpp" // 引入前面的线程封装
#include "Cond.hpp" // 引入条件变量的封装
namespace ThreadPoolModule
{
using namespace ThreadModule;
using namespace LogModule;
using namespace CondModule;
int default_thread_nums = 6;
template <class T>
class ThreadPool
{
private:
void AwakenOneThread()
{
if (_cond.Signal())
LOG(LogLevel::INFO) << "Wake up a thread";
else
LOG(LogLevel::INFO) << "Failed to wake a thread";
}
void AwakenAllThread()
{
if (_cond.Broadcast())
LOG(LogLevel::INFO) << "Wake up all threads";
else
LOG(LogLevel::INFO) << "Failed to wake up all threads";
}
// 将构造私有化-单例模式构造线程池
ThreadPool(int nums = default_thread_nums)
: _thread_nums(nums),
_is_running(false),
_sleep_thread_nums(0)
{
for (int i = 0; i < _thread_nums; i++)
{
// _threads.emplace_back(TaskHandler(nullptr)); // 这样插入的是TaskHandler-不是func_t类型的可调用对象
// 插入的是func_t类型的可调用对象-lambda表达式,不是TaskHandler
_threads.emplace_back(
[this](void *args)
{
this->TaskHandler(args);
});
}
}
// 禁用拷贝构造和赋值重载
ThreadPool(const ThreadPool<T> &) = delete;
ThreadPool<T> &operator=(const ThreadPool<T>) = delete;
public:
// 外部只能以单例的方式获取线程池
static ThreadPool<T> *GetSingleton(int nums)
{
if (_singleton_ptr == nullptr)
{
LOG(LogLevel::INFO) << "Attempt to obtain the singleton";
// 存在多个进程调用的情况,但是只能有一个进程访问并初始化_singleton_ptr临界资源
// 双层if-提高效率+解决互斥问题
LockGuard lock_guard(ThreadPool<T>::_mutex_init);
if (_singleton_ptr == nullptr)
{
_singleton_ptr = new ThreadPool<T>(nums);
LOG(LogLevel::INFO) << "Singleton creation successful";
}
}
return _singleton_ptr;
}
// 释放资源
~ThreadPool()
{
if (_is_running)
{
Stop();
JoinThreads();
}
}
// 启动线程-外部自主选择启动
void Start()
{
if (_is_running)
return;
_is_running = true;
for (auto &thread : _threads)
{
if (thread.Start())
{
LOG(LogLevel::INFO) << thread.GetName() << " start";
}
}
}
// 停止线程池
void Stop()
{
// 通过修改标志位在TaskHandler的时候停止
if (!_is_running)
return;
_is_running = false;
// 不仅仅只修改标志位,广播给所有线程,标志位已经修改
AwakenAllThread();
}
// 作为一个中转,调用外部任务
void TaskHandler(void *args)
{
Thread *self = static_cast<Thread *>(args);
while (true)
{
T t;
{
LockGuard lock_guard(_mutex_resource);
// 1. 等待理任务
// 任务队列为空 && 线程池处于运行状态就在条件变量上等待处理任务
while (_task_queue.empty() && _is_running)
{
_sleep_thread_nums++;
_cond.Wait(_mutex_resource.GetRawPtr());
_sleep_thread_nums--;
}
// 2. 退出->外部主动Stop()后在此处停下
// 任务队列为空 && 线程池不处于运行状态
if (_task_queue.empty() && !_is_running)
{
LOG(LogLevel::INFO) << "Thread pool stopped";
break;
}
// 3. 处理任务(任务队列不为空 && 线程池不退出)
t = _task_queue.front();
_task_queue.pop();
}
// 处理任务不需要在临界区内部进行(高效的原因之一)
// LOG(LogLevel::DEBUG) << self->GetName() << " get code: " << t;
// 统一的调用方式-无参无返回值
t();
}
}
// 向任务队列入任务
void Enqueue(const T &t)
{
// 不考虑任务的上限
if (_is_running)
{
LockGuard lock_guard(_mutex_resource);
_task_queue.push(t);
// 存在休眠线程
if (_sleep_thread_nums)
{
_sleep_thread_nums == _threads.size() ? AwakenAllThread() : AwakenOneThread();
}
}
}
// 回收线程
void JoinThreads()
{
for (auto &thread : _threads)
{
if (thread.Join())
{
LOG(LogLevel::INFO) << "Join " << thread.GetName();
}
}
}
private:
int _thread_nums; // 线程个数-固定线程池
std::vector<Thread> _threads; // 线程
std::queue<T> _task_queue; // 任务队列
Mutex _mutex_resource; // 互斥锁
Cond _cond; // 条件变量
bool _is_running; // 运行状态标志位
int _sleep_thread_nums; // 休眠线程数量
static ThreadPool<T> *_singleton_ptr; // 单例指针-静态成员在类外定义
static Mutex _mutex_init; // 创建单例的互斥锁
};
template <class T>
ThreadPool<T> *ThreadPool<T>::_singleton_ptr = nullptr;
template <class T>
Mutex ThreadPool<T>::_mutex_init;
}
现在先模拟实现一些任务,用于测试,后面到了网络部分就可以直接用现在我们写好的模块了
cpp
// Task.hpp
#pragma once
#include <iostream>
#include <functional>
#include <unistd.h>
namespace TaskModule
{
// 任务形式2(函数)
using task_t = std::function<void()>;
void Download()
{
std::cout << "Start downloading\n......" << std::endl;
usleep(10000);
std::cout << "Download completed" << std::endl;
}
void Upload()
{
std::cout << "Start uploading......" << std::endl;
usleep(10000);
std::cout << "Upload completed" << std::endl;
}
// 任务形式1(类)
class Task
{
public:
Task(int x, int y) : _x(x), _y(y) {}
~Task() {}
void Calculation()
{
_result = _x + _y;
}
int Result()
{
return _result;
}
int X()
{
return _x;
}
int Y()
{
return _y;
}
private:
int _x;
int _y;
int _result;
};
}
下面测试一下:
cpp
// main.cc
#include <iostream>
#include <memory>
#include "Log.hpp"
#include "Thread.hpp"
#include "ThreadPool.hpp"
#include "Task.hpp"
using namespace LogModule;
using namespace ThreadModule;
using namespace ThreadPoolModule;
int main()
{
// 任务码的形式,将ThreadPool.hpp中的TaskHandler中的执行方式更改即可
// ThreadPool<int> *thread_pool = ThreadPool<int>::GetSingleton(5);
// ENABLE_FILE_STRATEGY();
// int cnt = 10;
// thread_pool->Start();
// while (cnt)
// {
// thread_pool->Enqueue(cnt--);
// sleep(1);
// }
// thread_pool->Stop();
// sleep(2);
// std:: cout << "Ending!" << std::endl;
// thread_pool->JoinThreads();
using namespace TaskModule;
ENABLE_FILE_STRATEGY();
ThreadPool<task_t> *ptr = ThreadPool<task_t>::GetSingleton(5);
ptr->Start();
int cnt = 10;
ptr->Start();
while (cnt)
{
ptr->Enqueue(Upload);
cnt--;
sleep(1);
}
sleep(3);
ptr->Stop();
ptr->JoinThreads();
return 0;
}
运行可以看到在控制台输出:
bash
$ ./thread_pool
create_directory success!
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
$
在对应的同级目录中,日志文件同样正常输出
log
# log/ThreadPool.log
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [62] - Attempt to obtain the singleton
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [69] - Singleton creation successful
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[1] start
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[2] start
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[3] start
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[4] start
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[5] start
[2025-11-06 08:57:36] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:37] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:38] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:39] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:40] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:41] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:42] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:43] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:44] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[1]
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[2]
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[3]
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[4]
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[5]
// std:: cout << "Ending!" << std::endl;
// thread_pool->JoinThreads();
using namespace TaskModule;
ENABLE_FILE_STRATEGY();
ThreadPool<task_t> *ptr = ThreadPool<task_t>::GetSingleton(5);
ptr->Start();
int cnt = 10;
ptr->Start();
while (cnt)
{
ptr->Enqueue(Upload);
cnt--;
sleep(1);
}
sleep(3);
ptr->Stop();
ptr->JoinThreads();
return 0;
}
运行可以看到在控制台输出:
```bash
$ ./thread_pool
create_directory success!
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
Start uploading......
Upload completed
$
在对应的同级目录中,日志文件同样正常输出
log
# log/ThreadPool.log
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [62] - Attempt to obtain the singleton
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [69] - Singleton creation successful
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[1] start
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[2] start
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[3] start
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[4] start
[2025-11-06 08:57:35] [INFO] [ThreadPool.hpp] [93] - Thread[5] start
[2025-11-06 08:57:36] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:37] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:38] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:39] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:40] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:41] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:42] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:43] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:44] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [31] - Wake up all threads
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [128] - Thread pool stopped
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[1]
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[2]
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[3]
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[4]
[2025-11-06 08:57:48] [INFO] [ThreadPool.hpp] [163] - Join Thread[5]