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::asyncstd::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);

参数详解

  1. pthread_t *thread

    • 输出参数,指向一个 pthread_t 类型的变量,用于存储新创建线程的唯一标识符(线程 ID)。
    • 后续可通过该 ID 操作线程(如 pthread_join 等待线程结束)。
  2. const pthread_attr_t *attr

    • 输入参数,指向线程属性结构体 pthread_attr_t 的指针,用于设置线程的属性(如栈大小、调度策略、detach 状态等)。
    • 若为 NULL,则使用默认属性。
  3. void *(*start_routine)(void *)

    • 函数指针,指向线程的入口函数(线程启动后执行的函数)。
    • 该函数的返回值和参数均为 void * 类型,支持灵活传递任意数据(需显式类型转换)。
    • 线程执行完此函数后会自动终止。
  4. 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);

参数详解

  1. F&& f

    • 线程入口函数(可调用对象),支持:
      • 普通函数、静态成员函数;
      • lambda 表达式;
      • 非静态成员函数(需同时传递对象指针,见示例);
      • 函数对象(重载 operator() 的类实例)。
  2. 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)

操作系统内核为每个线程分配的唯一标识符,用于内核调度和管理线程。

特点

  1. 全局唯一性:在整个系统中唯一(不同进程的线程 ID 不会重复)。
  2. 内核可见性:内核通过该 ID 跟踪线程状态(如运行、阻塞)、分配 CPU 时间片。
  3. 依赖操作系统
    • Linux 中,内核线程 ID(TID)是一个整数,可通过 gettid() 系统调用获取(返回值类型为 pid_t,与进程 ID(PID)共享同一命名空间)。
    • Windows 中,内核线程 ID 是 DWORD 类型,可通过 GetCurrentThreadId() 获取。

用途

  • 内核级调试(如 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_tDWORDopaque 类型(如结构体、指针)
主要用途内核调度、系统级调试应用程序内线程管理(joindetach 等)
可获取性需要系统调用(如 gettid()线程库函数(如 pthread_self()

联系:用户态线程 ID 通常与内核线程 ID 存在映射关系(如 pthread_t 内部可能包含内核 TID),但应用程序一般无需直接操作内核 ID。

四、常见误区

  1. 线程 ID 与进程 ID 的关系: 线程是进程的子集,一个进程的多个线程共享进程 ID(PID),但各有独立的线程 ID(TID)。在 Linux 中,线程被视为 “轻量级进程”,TID 与 PID 同属一个数值空间(即 TID 可能与其他进程的 PID 重复)。

  2. 线程 ID 的可复用性: 线程终止后,其 ID 可能被内核或线程库重新分配给新线程(类似进程 ID 的复用机制),因此不应长期缓存已终止线程的 ID。

  3. 跨平台兼容性: 内核线程 ID 的获取方式(如 gettid())是非标准的,而用户态线程 ID(如 std::thread::id)在 C++ 标准中被统一定义,具有更好的跨平台性。

总结

线程 ID 是线程的唯一标识,分为内核级(系统全局)和用户态(进程内)两种。应用程序开发中,通常使用用户态线程 ID(如 pthread_tstd::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();

特性

  1. 调用 join 后,当前线程会阻塞,直到 std::thread 对象关联的线程执行完毕。
  2. 线程结束后,join 会回收线程资源,此后 std::thread 对象不再关联任何线程(joinable() 返回 false)。
  3. 必须对每个 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_npWaitForSingleObject),可避免永久阻塞。 4. 线程状态 **:join 只能对 “可连接”(joinable)的线程调用,已 detach 或已 join 的线程再次调用会出错。

总结

等待线程结束的 API 是多线程同步的基础工具,核心功能是阻塞当前线程直至目标线程完成。在 C/C++ 环境中,pthread_join(C 语言)和 std::thread::join(C++)是最常用的实现,需注意资源回收和死锁风险。

二、原子操作

原子操作(Atomic Operation)是并发编程中保证共享资源操作安全性的核心机制,其核心特性是不可分割性—— 操作一旦开始,就会在被任何其他线程打断前执行完毕,从而避免多线程并发访问时的竞态条件(Race Condition)。

一、为什么需要原子操作?

多线程环境中,对共享资源的非原子操作可能被线程调度打断,导致数据不一致。例如,看似简单的 i++ 实际包含三个步骤:

  1. 从内存读取 i 的值到 CPU 寄存器(读取);
  2. 在寄存器中对 i 加 1(修改);
  3. 将寄存器中的结果写回内存(写入)。

若两个线程同时执行 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_incatomic_dec 等宏定义。

三、C++ std::atomic 详解(最常用场景)

C++11 的 std::atomic 是原子操作的典型实现,支持基本数据类型(如 intlong、指针等)的原子操作,无需手动加锁即可保证线程安全。

1. 核心特性

  • 模板类std::atomic<T> 可实例化为任意可平凡复制的类型 T(如 intbool、指针等)。
  • 操作原子性:对 std::atomic 对象的读写、修改操作均为原子操作,无需额外同步机制。
  • 禁止拷贝std::atomic 不可拷贝或赋值(避免原子性被破坏),仅支持移动构造。

2. 常用操作

以 std::atomic<int> 为例,常用原子操作包括:

操作功能描述示例(a 为 std::atomic<int> 对象)
load()原子读取值(等价于 aint x = a.load(); 或 int x = a;
store(val)原子写入值(等价于 a = vala.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_relaxedmemory_order_acquire 等),例如:

a.store(5, std::memory_order_relaxed); // 仅保证原子性,不保证可见性/有序性(性能最高)
int x = a.load(std::memory_order_acquire); // 读取时获取内存屏障,保证可见性

四、原子操作 Vs 锁:适用场景对比

维度原子操作(如 std::atomic互斥锁(如 std::mutex
底层实现基于 CPU 原子指令,无上下文切换基于内核调度,可能触发上下文切换
性能极高(纳秒级),适合简单操作较低(微秒级),适合复杂逻辑
功能仅支持简单操作(读写、增减、CAS 等)支持任意复杂临界区代码
适用场景计数器、标志位、引用计数等简单共享资源多步操作的共享资源(如链表插入、复杂计算)

五、常见误区

  1. 混淆原子操作与 volatilevolatile 仅保证变量读写不被编译器优化(可见性),但不保证原子性(如 volatile int i; i++ 仍可能被打断)。而原子操作同时保证原子性和可见性。

  2. 认为原子操作可替代锁: 原子操作仅适用于简单场景,复杂逻辑(如 “检查 - 修改 - 操作” 多步流程)仍需锁,否则可能出现逻辑错误。

  3. 忽视内存序的影响: 错误的内存序可能导致可见性问题(如线程 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 的 SIGSEGVSIGFPE),最终终止进程及所有线程。
  • 例外情况:有限隔离机制。少数操作系统(如 Linux 通过 prctl 设置 PR_SET_DEATHSIG,或使用轻量级进程隔离技术)允许对线程错误进行隔离,但这需要额外的系统调用或工具(如 seccomp)配置,且无法完全避免进程级影响(如共享资源损坏仍可能导致进程退出),属于特殊场景而非默认行为。

综上,操作系统层面的核心逻辑是:线程依赖进程存在,进程的生命周期决定线程的存活边界;线程崩溃若破坏进程完整性,会触发进程整体终止