Multithread
https://cppguide.cn/pages/essentialsofcppserverprogrammingch03-08/
Thread
Synchronization utilities
High level abstraction of asynchronous operation
并发/并行
- 线程管理(C++11/17):
std::thread,std::mutex,std::lock_guard。std::async和std::future。
- 原子操作与无锁编程(C++11/17):
std::atomic,compare_exchange_strong。
- 协程(C++20):
- 异步 I/O、事件驱动编程的简化。
六、多线程与并发(<thread>, <mutex>, <future>)
C++11 引入了标准多线程库,无需依赖平台特定 API(如 Windows 线程、POSIX 线程):
<thread>:std::thread封装线程,支持创建和管理线程。<mutex>:互斥锁(std::mutex)、锁守卫(std::lock_guard),用于线程同步,避免数据竞争。<future>/<promise>:用于线程间通信,获取异步任务的结果。<atomic>:原子操作类型,用于无锁同步(如计数器)。
示例:多线程同步
#include <thread>
#include <mutex>
#include <iostream>
std::mutex mtx; // 互斥锁
int shared_count = 0;
void increment() {
std::lock_guard<std::mutex> lock(mtx); // 自动加锁/解锁
shared_count++;
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join(); // 等待线程结束
t2.join();
std::cout << shared_count << std::endl; // 输出 2(无数据竞争)
return 0;
}一、线程
Thread
In computer science, a thread of execution is the smallest sequence of programmed instructions that can be managed independently by a scheduler, which is typically a part of the operating system. 计算机科学中,线程是操作系统管理的、可以执行编制好的最小单位的指令序列的调度器。
创建
一、pthread_create
Linux 提供了 pthread 库来进行线程操作,这是 POSIX 标准的线程库。
pthread_create 是 POSIX 线程库(pthread)中创建线程的核心函数,其函数签名如下:
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);参数详解
-
pthread_t *thread- 输出参数,指向一个
pthread_t类型的变量,用于存储新创建线程的唯一标识符(线程 ID)。 - 后续可通过该 ID 操作线程(如
pthread_join等待线程结束)。
- 输出参数,指向一个
-
const pthread_attr_t *attr- 输入参数,指向线程属性结构体
pthread_attr_t的指针,用于设置线程的属性(如栈大小、调度策略、detach 状态等)。 - 若为
NULL,则使用默认属性。
- 输入参数,指向线程属性结构体
-
void *(*start_routine)(void *)- 函数指针,指向线程的入口函数(线程启动后执行的函数)。
- 该函数的返回值和参数均为
void *类型,支持灵活传递任意数据(需显式类型转换)。 - 线程执行完此函数后会自动终止。
-
void *arg- 传递给线程入口函数
start_routine的参数,类型为void *,可传递任意数据(如整数、结构体指针等)。
- 传递给线程入口函数
返回值
- 成功时返回
0; - 失败时返回非零错误码(如
EAGAIN表示系统资源不足,EINVAL表示属性无效),可通过strerror函数获取错误描述。
二、std::thread 构造函数(C++11 及以上)
C++11 引入的 std::thread 类封装了线程操作,其构造函数支持多种参数形式,核心目的是绑定线程入口函数及参数。最常用的构造函数签名如下:
#include <thread>
// 基本形式:绑定函数 f 及参数 args…
template <class F, class… Args>
explicit thread(F&& f, Args&&… args);参数详解
-
F&& f- 线程入口函数(可调用对象),支持:
- 普通函数、静态成员函数;
- lambda 表达式;
- 非静态成员函数(需同时传递对象指针,见示例);
- 函数对象(重载
operator()的类实例)。
- 线程入口函数(可调用对象),支持:
-
Args&&… args- 传递给入口函数
f的参数列表(数量可变,类型需与f的参数匹配)。 - 支持完美转发(保持参数的左值 / 右值属性)。
- 传递给入口函数
特殊场景说明
-
非静态成员函数作为入口:需将对象指针作为第一个参数,例如:
class MyClass { public: void func(int x) { /* … */ } }; MyClass obj; std::thread t(&MyClass::func, &obj, 42); // &obj 是对象指针,42 是 func 的参数 -
避免参数生命周期问题:若传递局部变量的指针 / 引用,需确保线程访问时变量未销毁,否则会导致未定义行为。
-
禁止拷贝,支持移动:
std::thread不可拷贝(拷贝构造函数被删除),但可移动(通过std::move转移所有权),例如:std::thread t1(func); std::thread t2 = std::move(t1); // t1 不再拥有线程,t2 接管
三、两者对比
| 维度 | pthread_create(Linux) | std::thread(C++) |
|---|---|---|
| 编程范式 | 基于 C 语言函数调用,需手动管理线程 ID 和属性 | 面向对象封装,支持 RAII(资源自动管理) |
| 入口函数参数 | 仅支持单个 void * 参数,需显式转换 | 支持可变参数(Args…),类型安全 |
| 错误处理 | 返回错误码,需手动检查 | 失败时抛出 std::system_error 异常 |
| 线程分离 /join | 需显式调用 pthread_detach 或 pthread_join | 需显式调用 join() 或 detach(),否则析构时程序终止 |
| 跨平台性 | 仅支持 POSIX 系统(如 Linux、macOS) | 标准 C++ 特性,支持所有符合标准的平台(如 Windows、Linux) |
C 主要函数说明:
pthread_create():创建线程,参数包括线程 ID、线程属性、线程函数和传递给线程的参数pthread_join():等待线程结束并回收资源- 线程函数必须返回
void*类型并接受void*类型的参数
C++ 线程的主要特点:
- 可以使用普通函数、lambda 表达式、类成员函数作为线程入口
std::thread类封装了线程操作join()方法等待线程完成,detach()方法将线程与主线程分离- 提供了
std::this_thread命名空间,包含线程相关的工具函数
两种方法的关系:C++ 的 <thread> 库在 Linux 系统上通常是基于 pthread 库实现的,是更高级的封装。
总结
pthread_create是 Linux 底层线程创建接口,灵活性高但需手动管理细节(如错误码、类型转换);std::thread是 C++ 封装的跨平台接口,简化了线程创建流程,支持类型安全和现代 C++ 特性(如 lambda、完美转发),推荐在 C++ 开发中优先使用。
线程 ID
线程 ID(Thread ID)是用于唯一标识进程内线程的标识符,在多线程编程中用于管理和操作线程。不同层面(操作系统内核、用户态库)对线程 ID 的定义和使用存在差异,具体如下:
一、内核级线程 ID(Kernel Thread ID)
操作系统内核为每个线程分配的唯一标识符,用于内核调度和管理线程。
特点
- 全局唯一性:在整个系统中唯一(不同进程的线程 ID 不会重复)。
- 内核可见性:内核通过该 ID 跟踪线程状态(如运行、阻塞)、分配 CPU 时间片。
- 依赖操作系统:
- Linux 中,内核线程 ID(TID)是一个整数,可通过
gettid()系统调用获取(返回值类型为pid_t,与进程 ID(PID)共享同一命名空间)。 - Windows 中,内核线程 ID 是
DWORD类型,可通过GetCurrentThreadId()获取。
- Linux 中,内核线程 ID(TID)是一个整数,可通过
用途
- 内核级调试(如
ps -T查看进程内线程,top -H监控线程 CPU 占用)。 - 线程调度和资源分配(如通过
sched_setaffinity绑定线程到特定 CPU 核心)。
二、用户态线程 ID(User-space Thread ID)
由用户态线程库(如 POSIX 的 pthread、C++ 的 std::thread)分配的标识符,用于应用程序层面的线程管理。
1. POSIX 线程库(pthread)中的 pthread_t
- 类型:通常是结构体指针或整数(具体实现依赖系统,但对外表现为 opaque 类型,不建议直接解析)。
- 唯一性:仅在当前进程内唯一(不同进程的
pthread_t可能重复)。 - 操作函数:
pthread_self():获取当前线程的pthread_t。pthread_equal(tid1, tid2):比较两个pthread_t是否为同一线程(避免直接用==,因可能是结构体)。
2. C++ std::thread 中的线程 ID
- 通过
std::thread::get_id()获取,类型为std::thread::id。 - 特性:
- 进程内唯一,默认构造的
std::thread::id表示 “无关联线程”。 - 支持流输出(
std::cout << t.get_id())和比较操作。 - 底层通常映射到
pthread_t或内核 TID(依赖标准库实现)。
- 进程内唯一,默认构造的
三、两者的区别与联系
| 维度 | 内核级线程 ID | 用户态线程 ID(如 pthread_t) |
|---|---|---|
| 分配者 | 操作系统内核 | 用户态线程库(如 libpthread) |
| 作用域 | 系统全局 | 仅当前进程内 |
| 数据类型 | 整数(如 pid_t、DWORD) | opaque 类型(如结构体、指针) |
| 主要用途 | 内核调度、系统级调试 | 应用程序内线程管理(join、detach 等) |
| 可获取性 | 需要系统调用(如 gettid()) | 线程库函数(如 pthread_self()) |
联系:用户态线程 ID 通常与内核线程 ID 存在映射关系(如 pthread_t 内部可能包含内核 TID),但应用程序一般无需直接操作内核 ID。
四、常见误区
-
线程 ID 与进程 ID 的关系: 线程是进程的子集,一个进程的多个线程共享进程 ID(PID),但各有独立的线程 ID(TID)。在 Linux 中,线程被视为 “轻量级进程”,TID 与 PID 同属一个数值空间(即 TID 可能与其他进程的 PID 重复)。
-
线程 ID 的可复用性: 线程终止后,其 ID 可能被内核或线程库重新分配给新线程(类似进程 ID 的复用机制),因此不应长期缓存已终止线程的 ID。
-
跨平台兼容性: 内核线程 ID 的获取方式(如
gettid())是非标准的,而用户态线程 ID(如std::thread::id)在 C++ 标准中被统一定义,具有更好的跨平台性。
总结
线程 ID 是线程的唯一标识,分为内核级(系统全局)和用户态(进程内)两种。应用程序开发中,通常使用用户态线程 ID(如 pthread_t、std::thread::id)进行线程管理,而内核级 ID 主要用于系统级调试和性能分析。理解两者的区别有助于正确处理线程生命周期和跨平台兼容性问题。
线程结束
等待线程结束的 API 用于阻塞当前线程,直到目标线程执行完毕,主要作用是同步线程执行顺序、回收线程资源,避免资源泄漏。不同编程环境和库提供的 API 有所不同,以下是常见的实现:
一、Linux/POSIX 线程库(pthread):pthread_join
pthread_join 是 POSIX 标准中等待线程结束的核心函数,用于阻塞调用线程,直到目标线程终止。
函数签名
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);参数解析
1.pthread_t thread:目标线程的 ID(由 pthread_create 输出)。
2.void **retval:输出参数,用于接收目标线程的返回值(即线程入口函数 start_routine 的返回值)。
- 若不需要返回值,可设为
NULL。 - 需注意:返回值需是线程生命周期外仍有效的内存(如全局变量、动态分配内存),避免使用局部变量指针。
返回值
- 成功返回
0; - 失败返回非零错误码(如
EDEADLK表示死锁,ESRCH表示线程不存在)。
示例
#include <pthread.h>
#include <stdio.h>
void* thread_func(void* arg) {
int* num = (int*)arg;
return (void*)(*num + 1); // 返回计算结果
}
int main() {
pthread_t tid;
int arg = 5;
void* result;
pthread_create(&tid, NULL, thread_func, &arg);
pthread_join(tid, &result); // 等待线程结束并获取返回值
printf("线程返回值:%ld\n", (long)result); // 输出:6
return 0;
}二、C++11 标准库:std::thread::join
C++11 中的 std::thread 类通过 join 方法实现线程等待,是对 pthread_join (Linux 下)或系统原生 API 的封装。
函数原型
#include <thread>
void join();特性
- 调用
join后,当前线程会阻塞,直到std::thread对象关联的线程执行完毕。 - 线程结束后,
join会回收线程资源,此后std::thread对象不再关联任何线程(joinable()返回false)。 - 必须对每个
std::thread对象调用一次join()或detach(),否则析构时会调用std::terminate终止程序。
示例
#include <thread>
#include <iostream>
void thread_func(int x) {
std::cout << "子线程执行,参数:" << x << std::endl;
}
int main() {
std::thread t(thread_func, 10);
t.join(); // 等待子线程结束
std::cout << "子线程已结束" << std::endl;
return 0;
}三、其他语言 / 环境的等待 API
1.** Windows 原生 API **:WaitForSingleObject
#include <windows.h>
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);- 参数
hHandle为线程句柄(CreateThread返回),dwMilliseconds为超时时间(INFINITE表示无限等待)。 - 等待成功返回
WAIT_OBJECT_0。
2.** Java **:Thread.join()
Thread t = new Thread(() -> { /* 线程逻辑 */ });
t.start();
t.join(); // 等待线程结束3.** Python **:threading.Thread.join()
import threading
t = threading.Thread(target=lambda: print("子线程执行"))
t.start()
t.join() # 等待线程结束四、核心注意事项
1.** 避免死锁 :若两个线程互相调用 join 等待对方,会导致死锁(如线程 A 等待线程 B,线程 B 同时等待线程 A)。
2. 资源回收 :join 不仅是同步机制,也是回收线程资源的关键操作(避免僵尸线程)。
3. 超时控制 :部分 API 支持超时等待(如 pthread_timedjoin_np、WaitForSingleObject),可避免永久阻塞。
4. 线程状态 **:join 只能对 “可连接”(joinable)的线程调用,已 detach 或已 join 的线程再次调用会出错。
总结
等待线程结束的 API 是多线程同步的基础工具,核心功能是阻塞当前线程直至目标线程完成。在 C/C++ 环境中,pthread_join(C 语言)和 std::thread::join(C++)是最常用的实现,需注意资源回收和死锁风险。
二、原子操作
原子操作(Atomic Operation)是并发编程中保证共享资源操作安全性的核心机制,其核心特性是不可分割性—— 操作一旦开始,就会在被任何其他线程打断前执行完毕,从而避免多线程并发访问时的竞态条件(Race Condition)。
一、为什么需要原子操作?
多线程环境中,对共享资源的非原子操作可能被线程调度打断,导致数据不一致。例如,看似简单的 i++ 实际包含三个步骤:
- 从内存读取
i的值到 CPU 寄存器(读取); - 在寄存器中对
i加 1(修改); - 将寄存器中的结果写回内存(写入)。
若两个线程同时执行 i++,可能出现如下错误序列:
- 线程 A 读取
i=0到寄存器; - 线程 B 读取
i=0到寄存器; - 线程 A 修改为 1 并写回内存(
i=1); - 线程 B 修改为 1 并写回内存(
i=1)。
最终结果应为 2,却得到 1—— 这就是非原子操作导致的竞态条件。而原子操作能保证 i++ 三个步骤 “一气呵成”,避免此类问题。
二、原子操作的本质:硬件与软件的协同
原子操作的实现依赖硬件支持和软件封装:
1. 硬件层面:CPU 的原子指令
现代 CPU 通过专用指令保证操作的原子性,例如:
- 总线锁定:x86 架构的
LOCK前缀指令(如LOCK ADD)会锁定系统总线,禁止其他 CPU 核心在指令执行期间访问内存,确保操作独占性。 - 缓存锁定:若操作的数据在 CPU 缓存中,部分 CPU(如 Intel)会通过缓存一致性协议(MESI)标记缓存行,避免其他核心修改,效率高于总线锁定。
这些硬件指令是原子操作的底层基础,保证了 “读取 - 修改 - 写入” 全流程的不可分割性。
2. 软件层面:编程语言的封装
编程语言通过库或关键字封装硬件原子指令,提供易用的原子操作接口。常见实现包括:
- C 语言:C11 标准引入
<stdatomic.h>头文件,定义atomic_int等原子类型及操作函数。 - C++:C++11 引入
std::atomic模板类,支持泛型原子操作。 - Linux 内核:提供
atomic_t类型及atomic_inc、atomic_dec等宏定义。
三、C++ std::atomic 详解(最常用场景)
C++11 的 std::atomic 是原子操作的典型实现,支持基本数据类型(如 int、long、指针等)的原子操作,无需手动加锁即可保证线程安全。
1. 核心特性
- 模板类:
std::atomic<T>可实例化为任意可平凡复制的类型T(如int、bool、指针等)。 - 操作原子性:对
std::atomic对象的读写、修改操作均为原子操作,无需额外同步机制。 - 禁止拷贝:
std::atomic不可拷贝或赋值(避免原子性被破坏),仅支持移动构造。
2. 常用操作
以 std::atomic<int> 为例,常用原子操作包括:
| 操作 | 功能描述 | 示例(a 为 std::atomic<int> 对象) |
|---|---|---|
load() | 原子读取值(等价于 a) | int x = a.load(); 或 int x = a; |
store(val) | 原子写入值(等价于 a = val) | a.store(5); 或 a = 5; |
fetch_add(val) | 原子加 val,返回操作前的值 | int prev = a.fetch_add(1);(等价于原子 i++) |
fetch_sub(val) | 原子减 val,返回操作前的值 | int prev = a.fetch_sub(1);(等价于原子 i--) |
exchange(val) | 原子替换为 val,返回旧值 | int old = a.exchange(10); |
compare_exchange_weak(expected, desired) | 比较并交换(CAS):若当前值等于 expected,则替换为 desired,返回是否成功 | bool success = a.compare_exchange_weak(exp, des); |
3. 关键操作:CAS(Compare-And-Swap)
compare_exchange_weak(及 compare_exchange_strong)是原子操作的核心,实现逻辑:
// 伪代码:CAS操作
bool compare_exchange(T& expected, T desired) {
if (当前值 == expected) {
当前值 = desired; // 原子替换
return true;
} else {
expected = 当前值; // 更新expected为实际值
return false;
}
}CAS 是无锁编程的基础,可实现复杂同步逻辑(如自旋锁、原子队列),但需注意 “ABA 问题”(值被修改后又改回原值,导致 CAS 误判)。
4. 内存序(Memory Order)
原子操作默认使用顺序一致性(sequentially consistent) 内存序,保证操作的全局可见性和有序性,但可能影响性能。实际使用中可通过指定内存序优化(如 memory_order_relaxed、memory_order_acquire 等),例如:
a.store(5, std::memory_order_relaxed); // 仅保证原子性,不保证可见性/有序性(性能最高)
int x = a.load(std::memory_order_acquire); // 读取时获取内存屏障,保证可见性四、原子操作 Vs 锁:适用场景对比
| 维度 | 原子操作(如 std::atomic) | 互斥锁(如 std::mutex) |
|---|---|---|
| 底层实现 | 基于 CPU 原子指令,无上下文切换 | 基于内核调度,可能触发上下文切换 |
| 性能 | 极高(纳秒级),适合简单操作 | 较低(微秒级),适合复杂逻辑 |
| 功能 | 仅支持简单操作(读写、增减、CAS 等) | 支持任意复杂临界区代码 |
| 适用场景 | 计数器、标志位、引用计数等简单共享资源 | 多步操作的共享资源(如链表插入、复杂计算) |
五、常见误区
-
混淆原子操作与
volatile:volatile仅保证变量读写不被编译器优化(可见性),但不保证原子性(如volatile int i; i++仍可能被打断)。而原子操作同时保证原子性和可见性。 -
认为原子操作可替代锁: 原子操作仅适用于简单场景,复杂逻辑(如 “检查 - 修改 - 操作” 多步流程)仍需锁,否则可能出现逻辑错误。
-
忽视内存序的影响: 错误的内存序可能导致可见性问题(如线程 A 的修改未被线程 B 看到),需根据场景选择合适的内存序。
六、总结
原子操作是并发编程中轻量级的线程安全机制,通过硬件原子指令保证操作不可分割,避免竞态条件。其核心优势是高性能(无锁开销),适用于简单共享资源(如计数器、标志位)。C++ 的 std::atomic 是原子操作的典型封装,提供了丰富的原子操作接口,配合内存序控制可在性能与安全性间取得平衡。对于复杂场景,仍需结合锁或其他同步机制使用。
问题
线程切换开销
假设一个操作系统中,线程上下文切换的开销为 4 微秒 (us)。CPU 的时间片长度为 8 毫秒 (ms),如果一个进程中有 4 个线程
且这些线程在一个时间片内均匀地轮流执行一次,那么线程上下下文切换的总开销占 CPU 时间的百分比是多少?
本题可先将时间单位统一,再计算出线程上下文切换的总开销以及一个时间片的总时长,最后计算线程上下文切换的总开销占 CPU 时间的百分比。
步骤一:统一时间单位
已知 1 毫秒(= 1000) 微秒,所以 CPU 时间片长度 8 毫秒换算为微秒是:(8\times1000 = 8000) 微秒。
步骤二:计算线程上下文切换的总开销
一个进程中有 4 个线程,这些线程在一个时间片内均匀地轮流执行一次,那么线程上下文切换的次数是(4 - 1 = 3) 次(因为第一次线程开始执行无需切换,从第一个线程到第二个线程切换 1 次,第二个到第三个切换 1 次,第三个到第四个切换 1 次,共 3 次切换)。又已知线程上下文切换的开销为 4 微秒 / 次,所以线程上下文切换的总开销为:(3\times4 = 12) 微秒。
步骤三:计算线程上下文切换的总开销占 CPU 时间的百分比
一个时间片的总时长为 8000 微秒,根据 “百分比(=)(线程上下文切换总开销(\div) 时间片总时长)(\times100%)”,可得:(\frac{12}{8000} \times 100% = 0.15%)
所以,答案选A 。
进程与线程
1. 主线程退出,支线程也将退出吗?
操作系统中,线程是进程的执行单元,所有线程共享进程的资源(如内存空间、文件描述符等),但线程的生命周期管理与进程是否存活及线程类型相关:
- 若主线程退出但进程未终止:主线程只是进程内的一个普通执行流,若其退出后进程仍存在(未调用
exit等终止进程的系统调用),其他支线程可继续运行,直至自身任务完成或被主动终止。 - 若主线程触发进程终止:若主线程执行了
exit、_exit等系统调用,或进程因其他原因(如所有非守护线程结束)被操作系统终止,此时整个进程的资源会被回收,所有支线程无论是否执行完毕,都会被强制退出。 - 特殊线程类型(如守护线程):部分操作系统或编程语言(依赖 OS 底层支持)中,守护线程的生命周期绑定于进程,当进程中所有非守护线程结束后,守护线程会被操作系统强制终止,无论其是否执行完毕。
2. 某个线程崩溃,会导致进程退出吗?
线程崩溃通常指线程触发了致命错误(如非法内存访问、除零、总线错误等),从操作系统层面看:
- 默认行为:进程会退出。线程共享进程的地址空间和内核数据结构(如进程控制块 PCB),线程崩溃可能破坏进程的关键资源(如堆内存、全局数据)或内核状态,导致进程无法继续正常运行。操作系统检测到此类错误后,会向整个进程发送终止信号(如 Linux 的
SIGSEGV、SIGFPE),最终终止进程及所有线程。 - 例外情况:有限隔离机制。少数操作系统(如 Linux 通过
prctl设置PR_SET_DEATHSIG,或使用轻量级进程隔离技术)允许对线程错误进行隔离,但这需要额外的系统调用或工具(如seccomp)配置,且无法完全避免进程级影响(如共享资源损坏仍可能导致进程退出),属于特殊场景而非默认行为。
综上,操作系统层面的核心逻辑是:线程依赖进程存在,进程的生命周期决定线程的存活边界;线程崩溃若破坏进程完整性,会触发进程整体终止。