1、线程等待pthread_join
1.1 为什么需要线程等待
回收线程资源:默认新建线程是joinable,线程退出后,如果没人join回收,线程库内部会保留该线程的退出信息,造成内存泄漏 ,类似僵尸进程,线程已经执行完退出,主线程需要很久再调用pthread_join,仍然可以拿到线程的返回值,说明线程退出信息被库保存了
获得子线程执行结果:主线程等待子线程执行完毕,拿到线程返回的结果
pthread_join是阻塞接口,如果目标线程还没执行完,主线程卡在pthread_join这里,阻塞等待,直到目标线程退出,函数才返回
1.2 函数原型
cpp
int pthread_join(pthread_t tid, void **retval);
- 参数1:tid,要等待的目标线程 ID(pthread_create 输出的 tid)
- 参数 2:void** retval,二级指针,输出型参数,用来接收线程返回值
- 返回值:成功返回
0;失败返回错误码(比如等待一个已经 detach 的线程,会失败)
子线程函数返回值类型是void* ,这个值保存在线程库维护的线程管理结构中,主线程定义void *ret变量(主线程栈上变量),把&ret(ret 的地址,类型void**)传给pthread_join ,pthread_join内部通过这个二级指针,把线程的返回值拷贝赋值给主线程的 ret 变量,拿到void*后,再强转成想要的类型
1.3 线程返回机制
cpp
void* thread_routine(void* args)
- 返回值是
void*,不只是只能返回整数,可以是任意堆上对象的指针:结构体、类对象、任务结果
注意 :不能返回线程栈上局部变量的地址 !线程退出后栈直接销毁,地址变成野指针,推荐在堆上 new/malloc 对象,返回堆地址;主线程拿到后,主线程负责free/delete释放
两种线程退出场景对应的返回值
正常 return 退出 :return (void*)10;,join 拿到的值就是 10,可以强转成 long long,被其他线程调用 pthread_cancel 取消终止 :pthread_join拿到的返回值固定为宏 PTHREAD_CANCELED,值等于(void*)-1
注意 :pthread_join 拿不到线程崩溃的信号信息,只要任意一个线程异常崩溃(除 0、段错误),整个进程直接崩溃,主线程根本没有机会调用 pthread_join 获取退出码
任务传递:
- 创建线程时:把任务结构体指针作为参数传给子线程
- 子线程执行任务,填充结果,返回该任务对象指针
- 主线程
pthread_join成功后,才去读取任务结果
这样保证子线程执行完毕之后主线程才访问结果,规避并发读写的竞争问题
1.4 线程中调用exec/fork
- 线程内调用 exec 程序替换
exec 替换的是整个进程的地址空间
,会替换整个进程的代码段、页表。调用后,当前进程所有其他线程直接失效崩溃
不推荐在线程直接 exec;如果一定要做:在线程内部fork创建子进程,子进程里调用 exec
- 线程内调用 fork
fork 可以在线程内调用。fork 之后,子进程只保留调用 fork 的这一个执行流,其他线程全部消失
子进程是全新独立进程,拥有独立地址空间,子进程内部可以再调用 pthread_create 创建自己的多线程
2、线程分离 pthread_detach
2.1 线程两种状态
1. joinable(默认状态,可连接)
线程退出后,资源(退出信息)不会自动释放,必须调用 pthread_join 回收,否则内存泄漏
2. detached(分离状态)
提前告诉线程库:我不关心这个线程的返回值,不需要 join 。线程一旦退出,线程库自动回收全部线程资源,不需要主线程 join。一旦线程被分离,不能再调用 pthread_join,调用直接失败
2.2 函数原型
cpp
int pthread_detach(pthread_t tid);
- 作用:将目标线程设置为分离态
- 两种分离方式:
- 主线程调用
pthread_detach(tid),分离别的线程 - 线程内部自己调用
pthread_detach(pthread_self());,自己分离自己
2.3 核心
- 分离只是修改线程库的状态,不会脱离进程
哪怕线程被 detach,它仍然属于当前进程,共享同一个地址空间。分离线程崩溃,整个进程依旧崩溃
- 主线程不能提前退出
主线程一旦 return,整个进程直接终止,所有分离的子线程会被强制杀死,任务没跑完就结束
| 项目 | joinable(默认) | detached(分离) |
|---|---|---|
| 资源回收 | 必须 pthread_join 手动回收,否则泄漏 | 线程退出自动回收,无需 join |
| 返回值 | pthread_join 可以拿到返回结果 | 无法获取返回值 |
| pthread_join 调用 | 允许 | 调用直接失败 |
| 适用场景 | 需要拿到子线程任务结果,等待任务完成 | 任务独立,不关心返回,服务器异步任务 |
2.4 示例
pthread_join 获取返回值
cpp
#include <pthread.h>
#include <stdio.h>
void* thread_func(void* arg)
{
sleep(1);
return (void*)10; // 线程正常返回
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, thread_func, NULL);
void* ret;
int n = pthread_join(tid, &ret);
if(n == 0){
printf("join success, ret = %lld\n", (long long)ret);
}
return 0;
}
!image.png
分离线程
cpp
#include <pthread.h>
#include <stdio.h>
void* thread_func(void* arg)
{
pthread_detach(pthread_self()); // 线程自己分离自己
sleep(2);
printf("子线程执行完毕\n");
return NULL;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, thread_func, NULL);
sleep(3); // 主线程不能立刻退出,否则子线程被杀
printf("main over\n");
return 0;
}
!image.png
3、C++面向对象封装pthread
目标:封装成Thread类,使用风格:
cpp
Thread t(回调任务);
t.start(); // 启动线程,创建内核LWP
t.join(); // 等待回收
3.1 类成员设计(thread.hpp)
cpp
#include <pthread.h>
#include <string>
#include <functional>
namespace thread_module {
// 线程状态枚举
enum class ThreadStatus {
NEW, // 对象创建,内核线程未创建
RUNNING, // 线程正在运行
STOP // 线程结束
};
class Thread {
private:
std::string _name;
pthread_t _tid; // pthread库线程id
ThreadStatus _status;
bool _joinable; // 是否可以join
using Callback = std::function<void()>;
Callback _cb; // 用户传入的任务回调
// ==========【最重要难点】static 静态入口函数 ==========
// pthread_create要求入口函数签名:void* (*)(void*)
// 普通成员函数自带隐藏this指针,参数变成 (Thread* this, void* arg),类型不匹配!
static void* thread_routine(void* args);
void setRunning(); // 设置状态为running
public:
// 构造:接收用户任务回调
Thread(Callback cb);
~Thread();
bool start();
bool join();
bool detach();
bool stop(); // pthread_cancel强制取消线程
// 调试打印接口
void printInfo();
static std::string statusToString(ThreadStatus s);
};
}
为什么线程入口必须加static?
- 普通成员函数:编译器自动添加第一个隐藏参数this,签名等价:
void* thread_routine(Thread* this, void* args)
pthread_create只接受void* (*)(void*),参数数量不匹配 → 编译报错
- static 静态成员函数 :属于类,不属于对象,没有 this 指针 ,签名完美匹配
void* (*)(void*) - 解决访问非静态成员:
pthread_create的第四个参数传this(当前对象指针)- 在
thread_routine内部把void* args强转回Thread* self - 通过
self->访问对象的_cb、_status等成员
cpp
void* Thread::thread_routine(void* args)
{
Thread* self = static_cast<Thread*>(args);
self->setRunning();
// 设置内核线程名字
pthread_setname_np(self->_tid, self->_name.c_str());
// 执行用户传入的任务回调
self->_cb();
// 任务执行完毕,修改状态为stop
self->_status = ThreadStatus::STOP;
return nullptr;
}
3.2 对外接口逻辑
- start()
- 调用
pthread_create(&_tid, nullptr, thread_routine, this) - 创建成功,内核 LWP 诞生;对象创建(构造函数)≠ 内核线程创建,对象和线程生命周期解耦
- 线程内部自己把状态改为
RUNNING,不要主线程直接修改状态,避免竞态
- detach() 分离
- 前置条件:线程必须是
RUNNING并且_joinable=true - 调用
pthread_detach(_tid),_joinable = false - 分离后:线程退出自动释放 TCB 资源,禁止 join,join 会直接失败
注意 :start()之后立刻detach,主线程执行速度快,新线程还没被内核调度、状态还没改成 RUNNING,detach 判断条件失败,分离失败。解决:pthread_create成功拿到 tid 就代表内核线程已经存在,可以直接修改状态,不要等子线程自己改状态再 detach
- stop()强制终止
- 前置:状态是
RUNNING - 调用
pthread_cancel(_tid),将状态置为STOP
- join
- 前置:
_joinable == true pthread_join(_tid, nullptr),主线程阻塞等待线程结束,回收线程资源- join 成功后标记线程状态为
STOP
3.3 线程传参方案
方案1:模板传参
把Thread做成模板类,构造接收一个参数,回调接收该参数。缺点:只能传一类参数
方案2:任务类 + lambda捕获
核心思想:任务本身封装所有参数,线程回调无参,用 lambda 捕获任务对象,解耦,不用模板可变参数
- 写任务类,任务内保存成员变量,提供execute()执行函数:
cpp
struct AddTask {
int _x, _y;
int _res;
AddTask(int x, int y):_x(x),_y(y),_res(0){}
void execute() {
_res = _x + _y;
}
};
- 创建任务对象,lambda捕获任务,传给Thread:
cpp
AddTask task(10,20);
Thread t(&{
task.execute();
});
t.start();
t.join();
优点:
- 想要多少参数,就在任务类写多少成员;不需要修改线程封装代码
- 线程回调永远无参,线程类不需要关心任务是什么,线程和任务解耦
3.4 批量管理多线程:先描述再组织
- 我们
Thread类就是对线程的描述; - 使用容器
std::vector<Thread>存放多个线程对象,完成组织
cpp
std::vector<Thread> threads;
// 创建10个线程对象
for(int i=0;i<10;i++){
threads.emplace_back([](){
// 任务逻辑
});
}
// 统一全部启动
for(auto& t : threads) t.start();
// 统一等待回收
for(auto& t : threads) t.join();
拓展脑洞:可以做用户态简易调度器,用队列保存线程,主线程循环 stop、restart 切换执行
3.5 线程内fork
- 线程可以调用
fork()创建子进程 fork之后,只有调用 fork 的那个线程会在子进程中保留,其他线程全部消失- 多线程环境 fork 极易造成死锁、资源混乱,工程尽量避免
4、抢票demo
4.1 代码逻辑
cpp
int tickets = 10000; // 全局共享资源
void get_ticket()
{
std::string name;
pthread_getname_np(pthread_self(), &name[0], 64);
while(true)
{
if(tickets > 0)
{
usleep(1000); // 模拟抢票业务耗时
printf("%s get ticket: %d\n", name.c_str(), tickets);
tickets--;
}
else
{
break;
}
}
}
int main()
{
Thread t1(get_ticket);
Thread t2(get_ticket);
Thread t3(get_ticket);
Thread t4(get_ticket);
t1.start();
t2.start();
t3.start();
t4.start();
t1.join();t2.join();t3.join();t4.join();
return 0;
}
运行现象:票数会抢到负数
4.2 为什么 tickets 变成负数
核心:tickets>0 判断 和 tickets-- 不是原子操作! 一条 C++ 语句会被编译成多条汇编指令,线程随时会被操作系统调度切走(上下文切换)
拆解tickets--汇编三步:
- 把内存
tickets读取到 CPU 寄存器 - 寄存器内值 -1
- 把寄存器的值写回内存
tickets
初始tickets = 1
- 线程 1:执行
if(tickets>0),把内存的1读到寄存器,判断1>0 → true,正要进入循环体,时间片耗尽,线程 1 被操作系统切走
- 线程上下文保存:寄存器里的值 = 1,PC 保存到 usleep 这一行
- 内存
tickets仍然是 1,没有执行 tickets--
- 调度器切换到线程 2:
- 线程 2 读取内存
tickets=1,判断>0成立,进入代码块,usleep 休眠
- 线程 2 读取内存
- 线程 2 休眠,切回线程 1 继续运行:
- 线程 1 上下文恢复,寄存器里的值还是 1,执行打印,然后
tickets--,内存变成 0
- 线程 1 上下文恢复,寄存器里的值还是 1,执行打印,然后
- 线程 2 被唤醒,执行打印,
tickets--,内存变成 1
结论:
tickets是共享资源 ;访问tickets的if判断、tickets--代码段叫做临界区- 多个执行流同时进入临界区 访问临界资源,就会产生数据竞争,数据不一致
- 显示器标准输出 stdout 也是共享资源,多线程 printf 不加保护会出现打印内容穿插乱码。
4.3 核心概念
- 共享资源:多个执行流都可以访问的资源(全局变量、文件、显示器)
- 临界资源 :需要保护的共享资源
- 临界区 :代码片段,访问临界资源的那一段代码
- 互斥:任何时刻,最多只有一个执行流进入临界区访问临界资源。
- 原子性 :操作不可拆分,一旦开始执行,不会被线程切换打断。
tickets--不是原子操作。