Linux:线程控制详解
Linux:线程控制详解
第1章 初识Linux线程
你有没有过这样的经历?打开电脑后,一边听音乐,一边写文档,还同时开着浏览器查资料——这些程序之所以能“同时”运行,背后其实是操作系统的多任务调度在支撑。但如果每个任务都用一个进程实现,会有什么问题?
比如你写一个下载工具,要同时下载5个文件。如果用5个进程,每个进程负责一个下载任务,会发现进程间共享配置(比如下载目录、限速设置)特别麻烦,而且进程创建和切换的开销也不小。这时候,线程就派上用场了。
1.1 什么是线程?——比进程更“轻”的执行流
线程(Thread)是进程内部的一个执行单元,也是CPU调度的基本单位。简单说,一个进程可以包含多个线程,这些线程共享进程的大部分资源(比如代码段、全局变量、堆空间、打开的文件),但各自拥有独立的栈结构和少量私有数据(比如线程ID、上下文信息)。
为什么说线程比进程“轻”?因为进程切换时,操作系统需要保存整个进程的地址空间、页表等资源,开销很大;而线程切换时,只需要保存线程的私有数据(栈、上下文),共享资源不用动,切换速度快得多。
1.2 线程与进程的核心区别:一句话讲清
很多人容易混淆进程和线程,这里有个关键结论你一定要记住:
进程是资源分配的基本单位,线程是CPU调度的基本单位。
举个例子:如果把进程比作一个“工厂”,工厂里有厂房(地址空间)、机器(资源);那么线程就是工厂里的“工人”,多个工人共享厂房和机器,但各自有自己的工具(栈、上下文),可以同时干活。
1.3 Linux的特殊:没有“真正的线程”,只有“轻量级进程”
和Windows不同,Linux内核里没有专门的线程概念——它是用“轻量级进程(Light Weight Process,LWP)”来模拟线程的。每个线程在 kernel 层面对应一个LWP,LWP有自己的PID(内核里叫TID),但多个LWP可以共享同一个进程的地址空间。
你可能会问:那我们平时用的pthread
库(比如pthread_create
创建线程)是怎么回事?
其实pthread
是用户级线程库(原生线程库),它在用户空间封装了LWP的操作:当你调用pthread_create
时,线程库会底层调用clone
系统调用创建LWP,同时维护线程的私有数据(比如线程ID、栈),让你在用户层感知到“线程”的存在,而不用直接操作LWP。
1.4 小实验:看看进程里的线程长什么样
光说不练假把式,我们来动手查看一个进程的线程。比如你运行一个多线程程序./mythread
,可以用以下命令:
-
查看线程的LWP(内核层面的ID):
ps -L -p 进程PID
其中-L
选项会显示每个LWP的信息,PID
是进程的ID(可以用ps aux | grep mythread
找到)。你会看到,同一个进程下有多个LWP,每个LWP对应一个线程。 -
实时查看线程占用的CPU:
top -H -p 进程PID
-H
选项会按线程显示,你能看到每个线程的CPU使用率、内存占用,甚至能观察到线程的调度切换。
试着跑一下这些命令,是不是发现一个进程里真的有多个“轻量级进程”在跑?这就是Linux线程的底层真相。
第2章 线程的创建:pthread_create
详解
要使用线程,第一步就是创建线程。Linux下最常用的是pthread
库的pthread_create
函数,这一节我们就吃透它——包括参数含义、线程ID的秘密、参数传递的坑。
2.1 pthread_create
:创建线程的“入口函数”
先看函数原型:
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
四个参数的作用,我们一个个说清楚:
-
pthread_t *thread
:输出参数,用来保存新创建线程的“线程ID”(注意:不是LWP的ID)。
pthread_t
是线程ID的类型,本质是一个无符号长整数(unsigned long
),但它的真实含义我们后面再讲。 -
const pthread_attr_t *attr
:线程属性,比如栈大小、调度优先级等。通常设为NULL
,表示使用默认属性。 -
void *(*start_routine)(void *)
:线程的“入口函数”,也就是线程创建后要执行的代码。
这是一个函数指针,要求函数返回值是void*
,参数也是void*
——这种设计是为了灵活传递任意类型的参数和返回值。 -
void *arg
:传递给入口函数的参数,可以是任意类型(因为是void*
),需要强转后使用。
返回值:成功返回0,失败返回错误码(注意:不是设置errno
,而是直接返回错误码)。
2.2 线程ID的秘密:不是LWP,而是“线程控制块地址”
你可能会疑惑:用pthread_create
得到的线程ID(pthread_t
),和用ps -L
看到的LWP ID,为什么不一样?
比如你写代码打印线程ID:
pthread_t tid;
pthread_create(&tid, NULL, my_routine, NULL);
printf("新线程ID:%lu\n", tid); // 打印出来是一个很大的数字,比如140704123456768
而用ps -L
看到的LWP ID是很小的整数(比如19172、19173)。这两个ID为什么不一样?
答案很关键:
- LWP ID:内核层面的ID,是
clone
系统调用创建的,用来标识内核中的轻量级进程,由操作系统调度使用。 pthread_t
(线程ID):用户空间线程库(pthread
)维护的ID,本质是线程控制块(Thread Control Block,TCB)的虚拟地址。
什么是TCB?
TCB是线程库为每个线程创建的“档案”,里面保存了线程的所有属性:线程ID、栈地址、入口函数地址、返回值等。因为TCB在pthread
库的内存空间里(属于进程的共享区),所以它的地址(即pthread_t
)是用户空间的虚拟地址,能直接访问。
你可能会问:为什么不直接用LWP ID当线程ID?
因为LWP是内核概念,用户层不需要关心;而线程ID是线程库给用户的“接口”,通过它能快速找到TCB,进而操作线程(比如等待、取消)。
2.3 小实验1:创建单个线程,对比线程ID和LWP
我们写一段完整代码,创建一个新线程,打印它的线程ID和LWP ID:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h> // 用于getpid()、sleep()// 线程入口函数
void *thread_routine(void *arg) {// 打印进程ID、线程ID(pthread_t)、LWP ID(gettid()需要包含<sys/syscall.h>)printf("进程PID:%d\n", getpid());printf("线程ID(pthread_t):%lu\n", pthread_self()); // pthread_self()获取当前线程IDprintf("LWP ID:%ld\n", syscall(SYS_gettid)); // 通过系统调用获取LWP IDsleep(10); // 让线程多跑一会儿,方便查看return NULL;
}int main() {pthread_t tid;// 创建线程int ret = pthread_create(&tid, NULL, thread_routine, NULL);if (ret != 0) {printf("创建线程失败,错误码:%d\n", ret);return -1;}printf("主线程:新线程ID(pthread_t):%lu\n", tid);sleep(15); // 主线程等待,防止进程退出return 0;
}
编译运行(注意:编译多线程程序必须加-lpthread
链接线程库):
gcc -o mythread mythread.c -lpthread
./mythread
运行结果大概是这样:
主线程:新线程ID(pthread_t):140704123456768
进程PID:19172
线程ID(pthread_t):140704123456768
LWP ID:19173
同时用ps -L -p 19172
查看:
PID LWP TTY TIME CMD
19172 19172 pts/0 00:00:00 mythread
19172 19173 pts/0 00:00:00 mythread
你看:
- 进程PID是19172,两个LWP(19172、19173)共享同一个PID;
- 线程ID(140704123456768)是一个大数字(虚拟地址),和LWP ID(19173)完全不同。
这就验证了我们之前的结论:线程ID是TCB的地址,LWP是内核的轻量级进程ID。
2.4 小实验2:创建多个线程,管理线程ID列表
如果要创建多个线程(比如10个),怎么管理它们的线程ID?可以用一个数组或vector
(C++)保存所有pthread_t
,方便后续等待。
C++代码示例:
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>using namespace std;// 线程入口函数:打印线程编号和LWP
void *thread_routine(void *arg) {int thread_num = *(int *)arg; // 强转参数为整数(线程编号)printf("线程%d:LWP ID:%ld\n", thread_num, syscall(SYS_gettid));sleep(5);return NULL;
}int main() {vector<pthread_t> tids; // 保存所有线程的IDint thread_count = 5; // 创建5个线程// 1. 创建5个线程for (int i = 0; i < thread_count; i++) {pthread_t tid;// 注意:这里必须用new分配内存,不能传栈上的i(原因后面讲)int *num = new int(i);int ret = pthread_create(&tid, NULL, thread_routine, num);if (ret != 0) {cout << "创建线程" << i << "失败,错误码:" << ret << endl;continue;}tids.push_back(tid);cout << "主线程:创建线程" << i << ",线程ID:" << tid << endl;}// 2. 等待所有线程结束(后面会讲pthread_join)for (pthread_t tid : tids) {pthread_join(tid, NULL);}cout << "所有线程执行完毕" << endl;return 0;
}
编译运行:
g++ -o multi_thread multi_thread.cpp -lpthread -std=c++11
./multi_thread
运行结果会显示5个线程的LWP ID,每个线程都有自己的LWP,证明创建成功。
2.5 线程参数传递的“坑”:栈变量vs堆变量
上面的代码里,为什么我们要用new int(i)
分配堆内存,而不是直接传&i
(栈上的变量)?
如果我们改成这样:
// 错误示例!
for (int i = 0; i < thread_count; i++) {pthread_t tid;// 直接传栈上的i的地址int ret = pthread_create(&tid, NULL, thread_routine, &i);...
}
运行后会发现:多个线程打印的“线程编号”是乱的,比如线程0打印3,线程1打印4——这是为什么?
原因很简单:i
是主线程栈上的变量,for循环执行速度很快,在新线程来得及读取i
的值之前,i
已经被主线程修改了(比如i从0变1、2、3…)。新线程拿到的&i
指向的是同一个栈地址,自然会读到脏数据。
而用new int(i)
在堆上分配内存,每个线程拿到的是独立的堆地址,即使主线程继续循环,堆上的数据也不会被覆盖——这是线程参数传递的关键原则:
如果参数需要长期存在(直到新线程使用完毕),必须用堆内存(new/malloc),不能用栈内存。
当然,新线程使用完堆内存后,要记得delete
,避免内存泄漏:
void *thread_routine(void *arg) {int thread_num = *(int *)arg;delete (int *)arg; // 释放堆内存...return NULL;
}
2.6 进阶:传递对象给线程(C++)
除了传递整数、字符串,我们还可以传递自定义对象给线程——这在实际开发中很常用(比如传递任务参数、配置信息)。
示例:传递一个“任务请求”对象给线程,线程计算1到N的和并返回结果。
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 1. 定义任务请求类(输入参数)
class TaskRequest {
public:int start; // 求和起始值int end; // 求和结束值string thread_name; // 线程名称// 构造函数TaskRequest(int s, int e, string name) : start(s), end(e), thread_name(name) {}
};// 2. 定义任务结果类(返回值)
class TaskResult {
public:int sum; // 求和结果bool is_success; // 是否计算成功TaskResult(int s, bool success) : sum(s), is_success(success) {}
};// 3. 线程入口函数:计算start到end的和
void *calc_sum(void *arg) {// 强转参数为TaskRequest指针TaskRequest *req = static_cast<TaskRequest *>(arg);cout << "线程" << req->thread_name << ":开始计算" << req->start << "到" << req->end << "的和" << endl;int sum = 0;for (int i = req->start; i <= req->end; i++) {sum += i;usleep(1000); // 模拟计算耗时}// 创建结果对象(堆内存),用于返回TaskResult *res = new TaskResult(sum, true);delete req; // 释放请求对象的内存return res; // 返回结果对象的指针
}int main() {// 创建任务请求:计算1到100的和,线程名"SumThread-1"TaskRequest *req = new TaskRequest(1, 100, "SumThread-1");pthread_t tid;// 创建线程int ret = pthread_create(&tid, NULL, calc_sum, req);if (ret != 0) {cout << "创建线程失败,错误码:" << ret << endl;delete req;return -1;}// 等待线程结束并获取结果(后面讲pthread_join)void *result;pthread_join(tid, &result);// 强转结果为TaskResult指针TaskResult *res = static_cast<TaskResult *>(result);if (res->is_success) {cout << "线程执行成功!1到100的和为:" << res->sum << endl;}delete res; // 释放结果对象的内存return 0;
}
编译运行:
g++ -o thread_obj thread_obj.cpp -lpthread -std=c++11
./thread_obj
运行结果:
线程SumThread-1:开始计算1到100的和
线程执行成功!1到100的和为:5050
这个例子展示了线程参数传递的灵活性:通过对象可以封装多个参数(start、end、name),返回值也可以封装结果和状态——这比单纯传递整数或字符串实用得多。
第3章 线程的等待
创建线程后,有个很重要的问题:主线程和新线程谁先执行?谁该最后退出?
你可能会说:“不一定吧,看操作系统调度。” 没错,但有一条铁律必须遵守:主线程必须最后退出。如果主线程先退出,整个进程会被销毁,所有新线程也会跟着强制终止——哪怕新线程的任务还没做完。
那怎么保证主线程最后退出?这就需要pthread_join
函数来“等待”新线程。
3.1 为什么必须等待?——两个核心目的
pthread_join
是线程的“等待函数”,作用是让调用者(通常是主线程)阻塞,直到目标线程执行完毕。为什么要等?主要有两个目的:
-
防止“线程资源泄漏”:
和进程一样,如果新线程执行完毕后没人“回收”它的资源(比如TCB、栈),这些资源会一直占用内存,形成类似“僵尸进程”的“僵尸线程”。pthread_join
的一个作用就是回收线程资源。 -
获取线程的执行结果:
新线程执行完任务后,可能会返回结果(比如计算结果、任务状态),pthread_join
可以获取这个返回值,让主线程知道任务执行情况。
3.2 pthread_join
:等待线程的“工具”
先看函数原型:
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
两个参数:
pthread_t thread
:要等待的目标线程的ID(pthread_t
)。void **retval
:输出参数,用来保存目标线程的返回值(即线程入口函数return
的值或pthread_exit
的参数)。如果不关心返回值,可以设为NULL
。
返回值:成功返回0,失败返回错误码。
3.3 关键难点:void **retval
为什么是二级指针?
很多人第一次用pthread_join
时,都会被void **retval
搞晕:为什么要用二级指针?用一级指针void *retval
不行吗?
我们先想一个问题:线程的返回值是void *
类型(比如前面例子中返回的TaskResult *
),我们要把这个返回值从线程库“拿”到主线程,该怎么做?
假设线程的返回值是res_ptr
(TaskResult *
类型),它保存在线程库的TCB里。主线程要获取它,需要:
- 主线程定义一个
void *
变量(比如void *result
); - 把这个变量的地址(
&result
,类型是void **
)传给pthread_join
; - 线程库内部把
res_ptr
的值赋给*result
(即result = res_ptr
); - 主线程再把
result
强转为TaskResult *
,就能拿到结果。
用通俗的话讲:retval
是一个“指针的指针”,它就像一把“钥匙”,让pthread_join
能把线程的返回值“写”到主线程的变量里。
举个例子(结合前面的calc_sum
线程):
// 主线程中
void *result; // 一级指针:保存线程返回值的地址
// 等待线程,把result的地址(二级指针)传给pthread_join
pthread_join(tid, &result);
// 强转为TaskResult*,获取结果
TaskResult *res = static_cast<TaskResult *>(result);
如果用一级指针void *retval
会怎么样?
pthread_join
会把返回值赋给retval
本身,但retval
是函数参数,是值传递,函数内部修改不会影响主线程的变量——所以必须用二级指针。
3.4 小实验1:等待单个线程,获取计算结果
我们用前面的“求和线程”例子,完整演示pthread_join
的使用:
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 任务请求类
class TaskRequest {
public:int start;int end;string name;TaskRequest(int s, int e, string n) : start(s), end(e), name(n) {}
};// 任务结果类
class TaskResult {
public:int sum;bool success;TaskResult(int s, bool succ) : sum(s), success(succ) {}
};// 线程入口函数
void *calc_sum(void *arg) {TaskRequest *req = static_cast<TaskRequest *>(arg);cout << "线程" << req->name << ":计算" << req->start << "~" << req->end << endl;int sum = 0;for (int i = req->start; i <= req->end; i++) {sum += i;usleep(500);}delete req;return new TaskResult(sum, true); // 返回结果对象
}int main() {// 1. 创建任务请求TaskRequest *req = new TaskRequest(1, 50, "Sum-1");pthread_t tid;// 2. 创建线程int ret = pthread_create(&tid, NULL, calc_sum, req);if (ret != 0) {cout << "创建线程失败,错误码:" << ret << endl;delete req;return -1;}// 3. 等待线程,获取结果void *result;ret = pthread_join(tid, &result);if (ret != 0) {cout << "等待线程失败,错误码:" << ret << endl;return -1;}// 4. 解析结果TaskResult *res = static_cast<TaskResult *>(result);if (res->success) {cout << "线程执行成功!1~50的和:" << res->sum << endl;} else {cout << "线程执行失败" << endl;}delete res; // 释放结果内存return 0;
}
运行结果:
线程Sum-1:计算1~50
线程执行成功!1~50的和:1275
完美!主线程通过pthread_join
不仅等待了新线程,还拿到了它的计算结果。
3.5 小实验2:批量等待多个线程,汇总结果
如果创建了多个线程(比如3个线程,分别计算1100、101200、201~300的和),怎么批量等待并汇总结果?
思路:用vector
保存所有线程ID和任务请求,然后循环调用pthread_join
,最后把每个线程的结果相加。
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>using namespace std;class TaskRequest {
public:int start;int end;string name;TaskRequest(int s, int e, string n) : start(s), end(e), name(n) {}
};class TaskResult {
public:int sum;bool success;TaskResult(int s, bool succ) : sum(s), success(succ) {}
};void *calc_sum(void *arg) {TaskRequest *req = static_cast<TaskRequest *>(arg);cout << "线程" << req->name << ":计算" << req->start << "~" << req->end << endl;int sum = 0;for (int i = req->start; i <= req->end; i++) {sum += i;usleep(300);}delete req;return new TaskResult(sum, true);
}int main() {vector<pthread_t> tids; // 保存线程IDvector<TaskRequest *> requests; // 保存任务请求(方便后续管理)// 1. 创建3个线程,分别计算1~100、101~200、201~300int ranges[3][2] = {{1, 100}, {101, 200}, {201, 300}};for (int i = 0; i < 3; i++) {string name = "Sum-" + to_string(i + 1);TaskRequest *req = new TaskRequest(ranges[i][0], ranges[i][1], name);requests.push_back(req);pthread_t tid;int ret = pthread_create(&tid, NULL, calc_sum, req);if (ret != 0) {cout << "创建线程" << name << "失败,错误码:" << ret << endl;delete req;continue;}tids.push_back(tid);cout << "主线程:创建线程" << name << ",ID:" << tid << endl;}// 2. 批量等待线程,汇总结果int total_sum = 0;for (size_t i = 0; i < tids.size(); i++) {void *result;int ret = pthread_join(tids[i], &result);if (ret != 0) {cout << "等待线程" << i + 1 << "失败,错误码:" << ret << endl;continue;}TaskResult *res = static_cast<TaskResult *>(result);if (res->success) {cout << "线程" << requests[i]->name << "结果:" << res->sum << endl;total_sum += res->sum;}delete res;}// 3. 输出总结果cout << "所有线程计算完成!1~300的总和:" << total_sum << endl;// 释放请求对象(虽然线程里已经delete,但这里防止创建线程失败时内存泄漏)for (TaskRequest *req : requests) {delete req;}return 0;
}
运行结果:
主线程:创建线程Sum-1,ID:140704123456768
主线程:创建线程Sum-2,ID:140704115064064
主线程:创建线程Sum-3,ID:140704106671360
线程Sum-1:计算1~100
线程Sum-2:计算101~200
线程Sum-3:计算201~300
线程Sum-1结果:5050
线程Sum-2结果:15050
线程Sum-3结果:25050
所有线程计算完成!1~300的总和:45150
这个例子展示了多线程协作的基本模式:分任务、并行计算、汇总结果——这也是多线程编程的核心场景之一。
3.6 关键原则:主线程必须最后退出!
再强调一次:无论你用不用pthread_join
,主线程都必须最后退出。如果主线程提前退出(比如没有sleep
也没有pthread_join
),进程会销毁,所有新线程都会被强制终止。
我们做个反面实验:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>void *thread_routine(void *arg) {printf("新线程:开始执行,我要跑10秒\n");sleep(10); // 模拟长时间任务printf("新线程:执行完毕\n");return NULL;
}int main() {pthread_t tid;pthread_create(&tid, NULL, thread_routine, NULL);printf("主线程:创建线程后直接退出\n");return 0; // 主线程提前退出
}
编译运行:
gcc -o bad_exit bad_exit.c -lpthread
./bad_exit
运行结果:
主线程:创建线程后直接退出
你会发现,新线程的“开始执行”都没打印出来——因为主线程退出后,进程被销毁,新线程还没来得及执行就被终止了。
所以,确保主线程最后退出的方法有两种:
- 用
pthread_join
等待所有新线程(推荐,主动回收); - 主线程做无限循环(比如服务器程序),不主动退出。
第4章 线程的终止
线程执行完任务后,需要正确终止,释放资源。Linux线程有三种终止方式,分别适用于不同场景,我们要分清它们的区别,避免用错。
4.1 方式1:线程入口函数return
——最自然的终止
如果线程的任务逻辑能在入口函数中完成,直接用return
返回即可——这是最自然、最推荐的方式。
比如前面的求和线程,任务完成后return new TaskResult(sum, true)
,线程就会终止,返回值会被pthread_join
获取。
注意:return
的返回值类型必须是void *
,如果不需要返回值,可以return NULL
。
4.2 方式2:pthread_exit
——主动终止当前线程
如果线程在执行过程中(比如在某个子函数里)需要提前终止,不能直接return
(因为return
只能退出当前子函数),这时候可以用pthread_exit
函数。
函数原型:
#include <pthread.h>
void pthread_exit(void *retval);
参数retval
和return
的返回值一样,是线程的退出状态,会被pthread_join
获取。
示例:线程执行到一半发现参数错误,提前终止:
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;void check_param(int start, int end) {if (start > end) {cout << "参数错误:start > end,线程提前终止" << endl;pthread_exit((void *)-1); // 返回错误码-1}
}void *calc_sum(void *arg) {TaskRequest *req = static_cast<TaskRequest *>(arg);check_param(req->start, req->end); // 检查参数int sum = 0;for (int i = req->start; i <= req->end; i++) {sum += i;}delete req;return new TaskResult(sum, true);
}int main() {// 故意传错误参数:start=100,end=50TaskRequest *req = new TaskRequest(100, 50, "Sum-Error");pthread_t tid;pthread_create(&tid, NULL, calc_sum, req);void *result;pthread_join(tid, &result);// 检查返回值:如果是-1,说明参数错误if (result == (void *)-1) {cout << "主线程:捕获到线程参数错误" << endl;}delete req;return 0;
}
运行结果:
参数错误:start > end,线程提前终止
主线程:捕获到线程参数错误
pthread_exit
的特点是:只终止当前线程,不会影响其他线程或进程——这是它和exit
的关键区别。
4.3 方式3:pthread_cancel
——取消目标线程
如果一个线程执行时间过长,或者任务不再需要(比如用户取消了操作),主线程可以用pthread_cancel
主动取消它。
函数原型:
#include <pthread.h>
int pthread_cancel(pthread_t thread);
参数thread
是要取消的目标线程的ID。返回值:成功返回0,失败返回错误码。
注意:pthread_cancel
不是“立即终止”线程,而是给目标线程发送一个“取消请求”。目标线程会在“取消点”(比如调用sleep
、usleep
、pthread_join
等系统调用时)响应请求,终止线程。
示例:主线程创建新线程后,1秒后取消它:
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;void *long_task(void *arg) {string name = *(string *)arg;cout << "线程" << name << ":开始执行长时间任务,预计跑10秒" << endl;// 循环执行,模拟耗时任务(usleep是取消点)for (int i = 0; i < 10; i++) {cout << "线程" << name << ":执行中..." << i << "秒" << endl;usleep(1000000); // 1秒,这是取消点}cout << "线程" << name << ":任务完成" << endl;return NULL;
}int main() {string thread_name = "LongTask-1";pthread_t tid;pthread_create(&tid, NULL, long_task, &thread_name);// 主线程等待1秒后,取消新线程sleep(1);cout << "主线程:取消线程" << thread_name << endl;int ret = pthread_cancel(tid);if (ret != 0) {cout << "取消线程失败,错误码:" << ret << endl;return -1;}// 等待线程终止,获取返回值void *result;pthread_join(tid, &result);// 被取消的线程,返回值是PTHREAD_CANCELED(宏定义,值为(void*)-1)if (result == PTHREAD_CANCELED) {cout << "主线程:线程" << thread_name << "已被取消" << endl;}return 0;
}
运行结果:
线程LongTask-1:开始执行长时间任务,预计跑10秒
线程LongTask-1:执行中...0秒
主线程:取消线程LongTask-1
主线程:线程LongTask-1已被取消
可以看到,新线程只执行了1秒就被取消了——因为usleep
是取消点,线程在调用usleep
时响应了取消请求。
4.4 误区:exit()
是终止进程,不是线程!
很多人会混淆exit()
和pthread_exit()
,以为exit()
能终止当前线程——这是大错特错!
exit()
是C标准库函数,作用是终止整个进程,无论在哪个线程中调用,都会导致所有线程(包括主线程)终止,进程退出。
我们做个实验验证:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h> // 包含exit()void *thread_routine(void *arg) {printf("新线程:调用exit(0)终止自己?\n");exit(0); // 错误!终止整个进程printf("新线程:这行不会执行\n");return NULL;
}int main() {pthread_t tid;pthread_create(&tid, NULL, thread_routine, NULL);sleep(5); // 主线程等待5秒printf("主线程:这行也不会执行\n");return 0;
}
运行结果:
新线程:调用exit(0)终止自己?
主线程的sleep(5)
和后续的打印都没执行——因为exit(0)
终止了整个进程,所有线程都没了。
所以,记住这个结论:
- 终止当前线程:用
pthread_exit
或return
; - 终止整个进程:用
exit
。
4.5 三种终止方式的对比
为了更清晰,我们做一个表格,对比三种线程终止方式:
终止方式 | 适用场景 | 是否影响其他线程 | 返回值获取 |
---|---|---|---|
return | 线程入口函数执行完毕 | 否 | pthread_join 可获取 |
pthread_exit | 线程内部提前终止(子函数中) | 否 | pthread_join 可获取 |
pthread_cancel | 主线程主动取消目标线程 | 否 | 返回PTHREAD_CANCELED |
exit | 终止整个进程 | 是(所有线程终止) | 无(进程直接退出) |
第5章 线程的资源
线程和进程最大的区别就是资源共享——多个线程共享进程的大部分资源,但也有少量私有资源。搞清楚“共享什么”和“私有什么”,是避免线程安全问题的关键。
5.1 共享资源大盘点:多个线程“共用”的资源
同一个进程内的所有线程,共享以下资源:
-
代码段(Text Segment):
进程的可执行代码,多个线程可以同时执行同一段代码(比如多个线程调用同一个函数)。这也是函数“可重入”的基础——如果函数没有全局变量或静态变量,多个线程调用时不会互相干扰。 -
数据段(Data Segment):
包括全局变量、静态变量(static
修饰的变量)。比如定义一个全局变量int g_count = 0
,所有线程都能读写它,修改后的值对其他线程可见。 -
堆空间(Heap):
用new
/malloc
分配的内存,属于进程共享。比如主线程new
一个对象,传给新线程,新线程可以修改这个对象的内容,主线程也能看到修改后的结果。 -
共享库(Shared Libraries):
进程加载的动态库(比如libpthread.so
、libc.so
),所有线程共享库的代码和数据。 -
打开的文件描述符(File Descriptors):
进程打开的文件、socket等,文件描述符表是进程共享的。比如主线程打开一个文件,新线程可以用同一个文件描述符读写该文件。 -
信号处理函数(Signal Handlers):
进程注册的信号处理函数,对所有线程生效。比如注册SIGINT
(Ctrl+C)的处理函数,无论哪个线程收到SIGINT
信号,都会执行这个处理函数。
5.2 私有资源详解:每个线程“独用”的资源
每个线程有自己的私有资源,其他线程无法直接访问:
-
线程栈(Thread Stack):
每个线程有自己独立的栈,用于保存函数调用链、局部变量、函数参数、返回值。栈的大小默认是8MB(可以通过线程属性修改),主线程的栈在进程地址空间的“栈区”,新线程的栈在“共享区”(pthread
库分配)。 -
线程ID(
pthread_t
):
每个线程有唯一的pthread_t
,由线程库维护,用于标识线程。 -
线程上下文(Thread Context):
包括CPU寄存器的值(比如程序计数器PC、栈指针SP)、线程的状态(运行、就绪、阻塞)等。线程切换时,操作系统会保存和恢复上下文。 -
线程局部存储(Thread-Local Storage,TLS):
用__thread
修饰的变量,每个线程有独立的副本,修改自己的副本不会影响其他线程。比如__thread int t_count = 0
,线程A把t_count
改成1,线程B的t_count
还是0。 -
信号掩码(Signal Mask):
每个线程可以设置自己的信号掩码,屏蔽某些信号。比如线程A屏蔽SIGUSR1
,线程B不屏蔽,那么SIGUSR1
信号只会递送给线程B。 -
errno变量:
虽然errno
看起来是全局变量,但实际上是线程私有的——每个线程有自己的errno
副本,避免多个线程修改errno
时互相干扰。
5.3 小实验1:验证栈的独立性——局部变量地址不同
栈是线程私有的核心资源,我们通过代码验证:多个线程定义同一个局部变量,打印它们的地址和值,看看是否独立。
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>using namespace std;void *thread_routine(void *arg) {int thread_num = *(int *)arg;int local_var = 0; // 局部变量,在当前线程的栈上// 打印线程编号、局部变量地址、初始值printf("线程%d:LWP=%ld,local_var地址=%p,初始值=%d\n", thread_num, syscall(SYS_gettid), &local_var, local_var);// 修改局部变量local_var = thread_num * 10;printf("线程%d:修改后local_var=%d\n", thread_num, local_var);delete (int *)arg;sleep(2); // 让线程多跑一会儿,方便观察return NULL;
}int main() {vector<pthread_t> tids;int thread_count = 3;// 创建3个线程for (int i = 0; i < thread_count; i++) {pthread_t tid;int *num = new int(i);pthread_create(&tid, &tid, thread_routine, num);tids.push_back(tid);}// 等待所有线程for (pthread_t tid : tids) {pthread_join(tid, NULL);}return 0;
}
运行结果:
线程0:LWP=19200,local_var地址=0x7f8b9a7f3edc,初始值=0
线程0:修改后local_var=0
线程1:LWP=19201,local_var地址=0x7f8b99ff2edc,初始值=0
线程1:修改后local_var=10
线程2:LWP=19202,local_var地址=0x7f8b997f1edc,初始值=0
线程2:修改后local_var=20
从结果能看出两个关键结论:
- 地址不同:三个线程的
local_var
地址完全不同,证明每个线程有自己独立的栈; - 值独立:线程0把
local_var
改成0,线程1改成10,线程2改成20,互不影响,证明栈上的变量是私有的。
5.4 小实验2:验证堆的共享性——传递堆对象
堆是共享的,我们通过代码验证:主线程在堆上创建一个对象,新线程修改对象的内容,主线程查看修改后的结果。
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 共享的堆对象
class SharedData {
public:int count;SharedData() : count(0) {}
};void *thread_routine(void *arg) {SharedData *data = static_cast<SharedData *>(arg);cout << "新线程:初始count=" << data->count << endl;// 修改堆对象的内容for (int i = 0; i < 5; i++) {data->count++;cout << "新线程:count=" << data->count << endl;usleep(500000);}return NULL;
}int main() {// 主线程在堆上创建对象SharedData *data = new SharedData();pthread_t tid;pthread_create(&tid, NULL, thread_routine, data);// 主线程等待1秒后,查看堆对象的内容sleep(1);cout << "主线程:查看count=" << data->count << endl;// 等待线程结束,释放堆对象pthread_join(tid, NULL);cout << "主线程:最终count=" << data->count << endl;delete data;return 0;
}
运行结果:
新线程:初始count=0
新线程:count=1
新线程:count=2
主线程:查看count=2
新线程:count=3
新线程:count=4
新线程:count=5
主线程:最终count=5
结果很明显:新线程修改了堆对象的count
,主线程能实时看到修改后的结果——证明堆是共享的。
5.5 思考:为什么栈必须私有?
你可能会问:既然堆、全局变量都是共享的,为什么栈必须私有?
因为栈的核心作用是保存函数调用链和局部变量——每个线程的调用链是独立的:
- 主线程可能在执行
main
函数,新线程可能在执行calc_sum
函数; - 即使两个线程执行同一个函数,它们的局部变量值也可能不同(比如前面实验中的
local_var
)。
如果栈是共享的,多个线程的函数调用会互相覆盖栈上的数据,导致程序崩溃。比如线程A的栈帧覆盖了线程B的返回地址,线程B执行return
时会跳转到错误的地址,引发段错误。
所以,栈的私有性是线程独立执行的基础——没有独立的栈,就没有独立的执行流。
5.6 注意:线程间没有“绝对的私有”
虽然栈是私有的,但这并不意味着其他线程“绝对不能访问”——因为所有线程共享同一个地址空间,只要知道栈上变量的地址,其他线程就能读写它。
我们做个实验:主线程获取新线程栈上变量的地址,然后修改它。
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 全局指针,用于保存新线程栈上变量的地址
int *g_local_ptr = NULL;void *thread_routine(void *arg) {int local_var = 0; // 栈上的局部变量g_local_ptr = &local_var; // 把地址保存到全局指针cout << "新线程:local_var地址=" << &local_var << ",初始值=" << local_var << endl;sleep(3); // 等待主线程修改cout << "新线程:local_var被修改后=" << local_var << endl;return NULL;
}int main() {pthread_t tid;pthread_create(&tid, NULL, thread_routine, NULL);// 等待新线程保存地址sleep(1);if (g_local_ptr != NULL) {cout << "主线程:获取到local_var地址=" << g_local_ptr << endl;*g_local_ptr = 100; // 修改新线程栈上的变量cout << "主线程:已将local_var修改为100" << endl;}pthread_join(tid, NULL);return 0;
}
运行结果:
新线程:local_var地址=0x7f9a8a7f3edc,初始值=0
主线程:获取到local_var地址=0x7f9a8a7f3edc
主线程:已将local_var修改为100
新线程:local_var被修改后=100
震惊!主线程竟然修改了新线程栈上的变量——这说明“私有”只是逻辑上的约定,不是物理上的隔离。
但请注意:实际开发中绝对不要这么做!因为线程的栈空间可能会被线程自身的函数调用覆盖,修改其他线程的栈变量很容易导致程序崩溃,而且极难调试。
第6章 线程库的底层实现
前面我们提到,Linux没有真正的线程,pthread
库是在用户空间封装了LWP。这一节我们深入底层,看看线程库是如何工作的——理解这部分,能帮你解决很多“奇怪”的线程问题。
6.1 线程库的角色:连接用户与内核
pthread
库(原生线程库)的核心作用是:在用户空间维护“线程”的概念,同时对接内核的LWP。
简单说,线程库做了两件关键的事:
- 用户层管理:创建线程控制块(TCB),维护线程ID、栈、返回值等私有数据;
- 内核层对接:调用
clone
系统调用创建LWP,让线程能被CPU调度。
你可以把线程库看作一个“翻译官”:用户说“我要创建一个线程”,线程库翻译成“创建一个LWP,再创建一个TCB关联它”;用户说“我要等待线程”,线程库翻译成“等待对应的LWP结束,再回收TCB资源”。
6.2 线程控制块(TCB):线程的“身份证”与“档案”
每个线程在用户空间都有一个“档案”——线程控制块(TCB),它是pthread
库内部定义的一个结构体,包含了线程的所有属性。
虽然TCB的具体结构是pthread
库的实现细节,但我们可以大致推测它包含以下字段:
// 伪代码:TCB的大致结构
struct pthread_tcb {pthread_t tid; // 线程ID(即TCB的地址)void *stack_addr; // 线程栈的起始地址size_t stack_size; // 线程栈的大小void *(*start_routine)(void *); // 线程入口函数void *arg; // 入口函数的参数void *retval; // 线程的返回值int detached; // 是否分离(0:可连接,1:分离)pthread_attr_t attr; // 线程属性// ... 其他字段(如信号掩码、局部存储等)
};
TCB的关键特点:
- 存储位置:TCB在
pthread
库的内存空间里,属于进程的“共享区”(因为pthread
是动态库,加载到共享区); - 线程ID的本质:
pthread_t
就是TCB的起始地址——因为通过地址能直接找到TCB,快速访问线程的所有属性; - 管理方式:线程库用链表或数组管理所有TCB,方便查找(比如
pthread_join
时,通过pthread_t
找到对应的TCB)。
6.3 线程栈的分配:为什么新线程的栈在共享区?
主线程的栈在进程地址空间的“栈区”(从高地址向低地址增长),而新线程的栈是pthread
库在“共享区”分配的——为什么?
因为主线程的栈是进程创建时内核分配的,大小固定(默认8MB);而新线程是线程库创建的,内核不知道它的存在,所以线程库必须自己在用户空间分配栈。
共享区是进程地址空间中专门用于动态库、共享内存的区域,pthread
库本身就加载在这里,所以线程库直接在共享区为新线程分配栈空间,既方便管理,又能保证栈的独立性。
线程栈的分配流程:
- 线程库调用
malloc
或mmap
在共享区分配一块内存(默认8MB),作为新线程的栈; - 把栈地址记录到TCB的
stack_addr
字段; - 调用
clone
系统调用时,把栈地址传给clone
,告诉内核“这个LWP用这块内存当栈”; - 新线程执行时,所有局部变量、函数调用都会用到这块栈空间。
6.4 clone
系统调用:创建LWP的底层接口
clone
是Linux特有的系统调用,比fork
更灵活——它可以控制子进程(LWP)与父进程共享哪些资源。pthread_create
底层就是调用clone
创建LWP的。
clone
的函数原型(简化版):
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);
关键参数:
fn
:LWP的入口函数(线程库会把用户的start_routine
封装成这个函数);child_stack
:新LWP的栈地址(即线程库在共享区分配的栈);flags
:控制共享资源的标志,比如:CLONE_VM
:共享地址空间(必须设置,否则就是创建进程);CLONE_FS
:共享文件系统信息;CLONE_FILES
:共享文件描述符表;CLONE_THREAD
:共享线程组ID(让多个LWP属于同一个进程);
arg
:传递给fn
的参数(即用户的arg
)。
pthread_create
调用clone
时,会设置CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_THREAD
等标志,确保新LWP与父进程共享所有资源——这就是线程能共享进程资源的底层原因。
6.5 1:1映射模型:用户级线程与LWP的对应关系
Linux的pthread
库采用“1:1映射”模型:一个用户级线程(TCB)对应一个内核LWP。
这种模型的特点:
- 优点:每个线程都有独立的LWP,能被CPU调度,支持真正的并行(多CPU核心同时执行多个线程);
- 缺点:线程切换需要陷入内核(因为LWP的调度由内核负责),切换开销比“N:1”模型(多个用户线程对应一个LWP)大。
为什么pthread
库选择1:1模型?因为Linux内核对LWP的支持很好,1:1模型能充分利用多核CPU,而且实现简单——这对追求性能的服务器程序很重要。
6.6 图解:线程在进程地址空间中的位置
为了更直观,我们用文字描述进程地址空间的布局,看看线程的TCB、栈在哪里:
进程地址空间(从高地址到低地址):
+------------------------+
| 内核空间(Kernel Space) | 高地址,内核代码/数据,用户态不可直接访问
+------------------------+
| 共享区(Shared Library) | 关键区域,线程库与新线程栈在此处
| - libpthread.so - | 原生线程库(pthread库)加载于此
| - TCB链表 | 所有线程的TCB(线程控制块)存放在此,通过线程ID(TCB地址)快速查找
| - 新线程栈(多个) | 每个新线程的栈由pthread库分配,默认8MB,独立不重叠
| - 其他共享库(libc.so等)| C标准库等动态库,所有线程共享其代码/数据
+------------------------+
| 堆(Heap) | 动态内存区(new/malloc分配),所有线程共享,需手动释放
+------------------------+
| 数据段(Data Segment) | 全局变量、静态变量(static),所有线程共享,生命周期与进程一致
+------------------------+
| 代码段(Text Segment) | 进程可执行代码,所有线程共享,只读(防止意外修改)
+------------------------+
| 栈(Stack) | 主线程的栈,内核分配,默认8MB,从高地址向低地址增长
+------------------------+
| 命令行参数/环境变量 | 进程启动时的参数与环境变量,所有线程共享
+------------------------+ 低地址
从这个布局能清晰看到:
- 主线程的栈在“栈区”,新线程的栈在“共享区”——这就是为什么主线程和新线程的栈地址范围完全不同;
- TCB在
libpthread.so
的内存区域,线程ID(pthread_t
)就是TCB的地址,通过这个地址能直接找到线程的所有属性(栈地址、返回值等); - 堆、数据段、代码段是所有线程的“公共区域”,而栈(主线程+新线程)和TCB是“私有区域”。
6.7 小实验:查看线程栈的实际地址
我们可以通过代码打印主线程和新线程的栈地址,验证它们的位置差异:
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 打印当前线程的栈地址(通过局部变量的地址间接获取)
void print_stack_addr(const string& thread_name) {int local_var; // 局部变量,在当前线程的栈上cout << thread_name << ":栈上局部变量地址 = " << &local_var << endl;
}void *new_thread_routine(void *arg) {print_stack_addr("新线程");sleep(3); // 让线程停留,方便观察return NULL;
}int main() {// 打印主线程的栈地址print_stack_addr("主线程");// 创建新线程,打印其栈地址pthread_t tid;pthread_create(&tid, NULL, new_thread_routine, NULL);pthread_join(tid, NULL);return 0;
}
编译运行结果(不同机器地址可能不同,但范围一定有差异):
主线程:栈上局部变量地址 = 0x7ffd8b7c3dac
新线程:栈上局部变量地址 = 0x7f8a9a7f3edc
对比两个地址:
- 主线程栈地址(0x7ffd…)属于“栈区”(高地址但低于共享区);
- 新线程栈地址(0x7f8a…)属于“共享区”(地址更低,靠近堆区)——这和我们前面的地址空间布局完全一致。
6.8 线程库的初始化:进程启动时的“隐形工作”
你可能会问:pthread
库是何时加载到进程中的?主线程的TCB是何时创建的?
其实,当你编译程序时链接-lpthread
,进程启动后会自动完成以下“隐形工作”:
- 加载
libpthread.so
:动态链接器会把pthread
库加载到进程的共享区; - 初始化主线程TCB:
pthread
库会为主线程创建一个TCB,记录主线程的栈地址(内核分配的栈区地址)、线程ID(pthread_self()
返回的就是这个TCB的地址)等属性; - 关联主线程与LWP:主线程对应的LWP就是进程的PID(因为进程启动时内核会创建一个LWP作为主线程),
pthread
库会把这个LWP与主线程的TCB关联起来。
也就是说,主线程并非“特殊”——它和新线程一样,都有自己的TCB和LWP,只是TCB的创建时机和栈的位置不同。
第7章 线程局部存储(TLS)
我们已经知道,线程的栈是私有的,但栈上的局部变量有个问题:函数调用结束后会被销毁。如果一个线程需要一个“全局可见,但只属于自己”的变量(比如线程的日志ID、用户身份信息),栈变量就不够用了——这时候需要线程局部存储(Thread-Local Storage,TLS)。
7.1 什么是TLS?——“每个线程一份”的全局变量
TLS是一种特殊的存储机制:用TLS修饰的变量,每个线程会有独立的副本,修改自己的副本不会影响其他线程,而且变量的生命周期与线程一致(线程创建时初始化,线程终止时销毁)。
你可以把TLS理解为“线程级的全局变量”:
- 全局变量:所有线程共享一份,修改对所有人可见;
- TLS变量:每个线程私有一份,修改只对自己可见;
- 栈变量:线程私有,但函数结束后销毁,跨函数无法访问。
7.2 为什么需要TLS?——解决“跨函数私有数据”问题
举个例子:一个线程需要在多个函数中使用“线程ID”,如果用栈变量,每次调用函数都要传递参数,很麻烦;如果用全局变量,多个线程会互相覆盖。这时候TLS就是最佳选择。
比如日志系统:每个线程打印日志时需要带上自己的线程ID,用TLS存储线程ID,每个函数打印日志时直接读取TLS变量,不用传递参数,也不用担心被其他线程修改。
7.3 TLS的两种使用方式:__thread
关键字与pthread_key
系列函数
Linux下使用TLS主要有两种方式:__thread
关键字(简单易用)和pthread_key
系列函数(灵活,支持自定义类型)。
7.3.1 方式1:__thread
关键字——最简单的TLS
__thread
是GCC的扩展关键字(C++11后被纳入标准,可直接使用),用于定义TLS变量。它的语法很简单:在变量声明前加__thread
。
使用规则:
- 只能修饰内置类型(int、char、指针等),不能修饰自定义类型(class、struct);
- 变量的初始化必须是编译期常量(比如
__thread int a = 10
,不能是__thread int a = rand()
); - 生命周期:线程创建时初始化,线程终止时自动销毁。
示例1:验证TLS变量的独立性
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>using namespace std;// 定义TLS变量:每个线程有独立副本
__thread int tls_count = 100;void *thread_routine(void *arg) {int thread_num = *(int *)arg;cout << "线程" << thread_num << ":初始tls_count = " << tls_count << endl;// 修改TLS变量(只影响当前线程的副本)tls_count += thread_num;cout << "线程" << thread_num << ":修改后tls_count = " << tls_count << endl;delete (int *)arg;sleep(2);return NULL;
}int main() {vector<pthread_t> tids;int thread_count = 3;// 创建3个线程for (int i = 0; i < thread_count; i++) {pthread_t tid;int *num = new int(i);pthread_create(&tid, NULL, thread_routine, num);tids.push_back(tid);}// 等待所有线程for (pthread_t tid : tids) {pthread_join(tid, NULL);}// 主线程的TLS变量不受影响cout << "主线程:tls_count = " << tls_count << endl;return 0;
}
编译运行结果:
线程0:初始tls_count = 100
线程0:修改后tls_count = 100
线程1:初始tls_count = 100
线程1:修改后tls_count = 101
线程2:初始tls_count = 100
线程2:修改后tls_count = 102
主线程:tls_count = 100
结果很明显:
- 每个线程的
tls_count
初始值都是100(编译期常量); - 线程0把它改成100,线程1改成101,线程2改成102,互不影响;
- 主线程的
tls_count
还是100,完全不受其他线程影响——这就是TLS的“私有性”。
示例2:TLS变量跨函数访问
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// TLS变量:存储当前线程的名称
__thread string *tls_thread_name = NULL; // 指针类型,支持动态分配// 子函数:读取TLS变量
void print_thread_name() {cout << "子函数:当前线程名称 = " << *tls_thread_name << endl;
}void *thread_routine(void *arg) {string name = *(string *)arg;// 为当前线程的TLS变量分配内存(每个线程独立)tls_thread_name = new string(name);cout << "线程入口:当前线程名称 = " << *tls_thread_name << endl;// 调用子函数,子函数直接读取TLS变量print_thread_name();delete (string *)arg;delete tls_thread_name; // 线程终止前释放TLS变量内存return NULL;
}int main() {string name1 = "Thread-A";string name2 = "Thread-B";pthread_t tid1, tid2;pthread_create(&tid1, NULL, thread_routine, &name1);pthread_create(&tid2, NULL, thread_routine, &name2);pthread_join(tid1, NULL);pthread_join(tid2, NULL);return 0;
}
运行结果:
线程入口:当前线程名称 = Thread-A
子函数:当前线程名称 = Thread-A
线程入口:当前线程名称 = Thread-B
子函数:当前线程名称 = Thread-B
这个例子展示了TLS的核心优势:跨函数访问私有数据。子函数print_thread_name
不用通过参数传递线程名称,直接读取TLS变量就能拿到当前线程的名称,代码更简洁。
7.3.2 方式2:pthread_key
系列函数——支持自定义类型的TLS
__thread
只能修饰内置类型,如果需要存储自定义类型(比如class
、struct
),或者需要更灵活的初始化/销毁逻辑,就需要用pthread_key
系列函数。
核心函数有4个:
pthread_key_create
:创建一个TLS密钥(用于标识TLS变量);pthread_setspecific
:为当前线程的密钥设置值(存储数据);pthread_getspecific
:从当前线程的密钥中获取值(读取数据);pthread_key_delete
:销毁TLS密钥(注意:不销毁已存储的数据,需手动释放)。
使用流程:
- 创建密钥时,可指定“销毁函数”(线程终止时自动调用,用于释放数据内存);
- 每个线程通过
pthread_setspecific
为密钥设置自己的数据(指针类型); - 每个线程通过
pthread_getspecific
获取自己的数据; - 进程结束前,用
pthread_key_delete
销毁密钥。
示例:用pthread_key
存储自定义类型
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;// 自定义类型:线程的私有数据
class ThreadData {
public:int thread_id;string thread_name;ThreadData(int id, string name) : thread_id(id), thread_name(name) {}~ThreadData() {cout << "ThreadData销毁:线程" << thread_id << endl;}
};pthread_key_t tls_key; // TLS密钥(全局唯一,所有线程共享)// 销毁函数:线程终止时自动调用,释放ThreadData内存
void tls_destroy(void *data) {if (data != NULL) {delete static_cast<ThreadData *>(data);}
}// 初始化TLS密钥(进程启动时调用)
void init_tls_key() {// 创建密钥,指定销毁函数tls_destroyint ret = pthread_key_create(&tls_key, tls_destroy);if (ret != 0) {cout << "创建TLS密钥失败,错误码:" << ret << endl;exit(1);}
}void *thread_routine(void *arg) {ThreadData *data = static_cast<ThreadData *>(arg);cout << "线程" << data->thread_id << ":初始化私有数据" << endl;// 为当前线程的密钥设置值(存储ThreadData指针)pthread_setspecific(tls_key, data);// 从密钥中获取当前线程的私有数据ThreadData *current_data = static_cast<ThreadData *>(pthread_getspecific(tls_key));cout << "线程" << current_data->thread_id << ":名称 = " << current_data->thread_name << endl;// 不需要手动delete data!销毁函数会自动调用return NULL;
}int main() {// 初始化TLS密钥init_tls_key();// 创建2个线程,传递自定义类型ThreadDataThreadData *data1 = new ThreadData(1, "Thread-1");ThreadData *data2 = new ThreadData(2, "Thread-2");pthread_t tid1, tid2;pthread_create(&tid1, NULL, thread_routine, data1);pthread_create(&tid2, NULL, thread_routine, data2);pthread_join(tid1, NULL);pthread_join(tid2, NULL);// 销毁TLS密钥(不影响已销毁的线程数据)pthread_key_delete(tls_key);cout << "主线程:TLS密钥已销毁" << endl;return 0;
}
运行结果:
线程1:初始化私有数据
线程1:名称 = Thread-1
ThreadData销毁:线程1
线程2:初始化私有数据
线程2:名称 = Thread-2
ThreadData销毁:线程2
主线程:TLS密钥已销毁
这个例子的关键亮点:
- 支持自定义类型
ThreadData
,解决了__thread
的限制; - 通过“销毁函数”
tls_destroy
,线程终止时自动释放ThreadData
内存,避免内存泄漏; - 所有线程共享同一个
tls_key
,但通过pthread_setspecific
和pthread_getspecific
访问的是各自的私有数据。
7.4 __thread
与pthread_key
的对比
特性 | __thread 关键字 | pthread_key 系列函数 |
---|---|---|
支持类型 | 仅内置类型(int、指针等) | 所有类型(包括自定义class/struct) |
初始化 | 仅支持编译期常量 | 支持动态初始化(运行时分配) |
销毁 | 自动销毁(线程终止时) | 需指定销毁函数(手动管理) |
跨库支持 | 支持(动态库中可使用) | 支持(需确保密钥全局可见) |
使用复杂度 | 简单(类似普通变量) | 复杂(需调用4个函数) |
适用场景 | 简单的线程私有数据(ID、计数器) | 复杂的私有数据(自定义类型) |
选择建议:
- 如果只是存储简单的内置类型,优先用
__thread
,代码更简洁; - 如果需要存储自定义类型,或需要自定义销毁逻辑,用
pthread_key
。
第8章 线程分离(pthread_detach)
我们之前讲过,pthread_join
是阻塞等待线程,如果主线程不想阻塞(比如要处理其他任务),又不想内存泄漏,该怎么办?——答案是线程分离(pthread_detach)。
8.1 什么是线程分离?——“自毁”的线程
默认情况下,新线程是“可连接(joinable)”状态:必须通过pthread_join
等待它终止,否则会导致资源泄漏(TCB、栈等无法释放)。
线程分离后,会变成“分离(detached)”状态:
- 不需要
pthread_join
等待,线程终止后会自动释放资源(由pthread
库负责); - 分离后的线程不能再调用
pthread_join
,否则会返回错误(非法参数)。
8.2 为什么需要线程分离?——释放主线程的“等待负担”
线程分离的核心场景是:主线程不关心线程的执行结果,只想让它“后台运行”,结束后自动清理。
比如:
- 服务器程序的“日志线程”:主线程启动日志线程后,日志线程一直后台打印日志,主线程不需要等它,也不关心它的结果;
- 定时任务线程:主线程启动一个线程定期清理临时文件,线程终止后自动释放资源,主线程不用管。
8.3 线程分离的两种方式:主线程分离与线程自分离
线程分离有两种实现方式,效果完全一致,只是调用者不同。
8.3.1 方式1:主线程调用pthread_detach
分离线程
主线程创建线程后,调用pthread_detach
将目标线程设为分离状态。
函数原型:
#include <pthread.h>
int pthread_detach(pthread_t thread);
参数thread
:要分离的目标线程ID;
返回值:成功返回0,失败返回错误码。
示例:主线程分离多个线程
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
#include <string.h> // 用于strerror()using namespace std;void *thread_routine(void *arg) {string name = *(string *)arg;cout << "线程" << name << ":开始执行,将后台运行5秒" << endl;// 模拟后台任务for (int i = 0; i < 5; i++) {cout << "线程" << name << ":运行中..." << i + 1 << "秒" << endl;usleep(1000000); // 1秒}cout << "线程" << name << ":执行完毕,将自动释放资源" << endl;delete (string *)arg;return NULL;
}int main() {vector<pthread_t> tids;vector<string *> names; // 保存线程名称,避免栈变量被覆盖// 创建3个线程并分离for (int i = 0; i < 3; i++) {string *name = new string("Thread-" + to_string(i + 1));names.push_back(name);pthread_t tid;int ret = pthread_create(&tid, NULL, thread_routine, name);if (ret != 0) {cout << "创建线程" << *name << "失败:" << strerror(ret) << endl;delete name;continue;}tids.push_back(tid);// 主线程分离线程ret = pthread_detach(tid);if (ret != 0) {cout << "分离线程" << *name << "失败:" << strerror(ret) << endl;} else {cout << "主线程:已分离线程" << *name << ",ID = " << tid << endl;}}// 主线程不调用pthread_join,而是做自己的任务cout << "主线程:开始处理自己的任务(等待10秒)" << endl;sleep(10); // 确保主线程比所有新线程后退出cout << "主线程:任务处理完毕,退出" << endl;// 释放名称内存(线程中已使用,这里防止创建线程失败时泄漏)for (string *name : names) {delete name;}return 0;
}
运行结果(部分):
主线程:已分离线程Thread-1,ID = 140704123456768
主线程:已分离线程Thread-2,ID = 140704115064064
主线程:已分离线程Thread-3,ID = 140704106671360
主线程:开始处理自己的任务(等待10秒)
线程Thread-1:开始执行,将后台运行5秒
线程Thread-1:运行中...1秒
线程Thread-2:开始执行,将后台运行5秒
线程Thread-2:运行中...1秒
线程Thread-3:开始执行,将后台运行5秒
线程Thread-3:运行中...1秒
...(5秒后)
线程Thread-1:执行完毕,将自动释放资源
线程Thread-2:执行完毕,将自动释放资源
线程Thread-3:执行完毕,将自动释放资源
...(主线程继续等待5秒)
主线程:任务处理完毕,退出
关键观察点:
- 主线程没有调用
pthread_join
,而是做自己的任务(等待10秒); - 新线程执行完毕后,资源自动释放,没有内存泄漏;
- 主线程必须确保自己最后退出(这里用
sleep(10)
),否则新线程会被强制终止。
8.3.2 方式2:线程自调用pthread_detach
分离自己
线程也可以在入口函数中调用pthread_self()
获取自己的线程ID,然后调用pthread_detach
分离自己——这种方式更灵活,不需要主线程干预。
示例:线程自分离
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;void *thread_routine(void *arg) {string name = *(string *)arg;pthread_t tid = pthread_self(); // 获取当前线程ID// 线程自分离int ret = pthread_detach(tid);if (ret != 0) {cout << "线程" << name << "自分离失败:" << strerror(ret) << endl;delete (string *)arg;return NULL;}cout << "线程" << name << ":已自分离,ID = " << tid << endl;// 模拟任务sleep(3);cout << "线程" << name << ":执行完毕,自动释放资源" << endl;delete (string *)arg;return NULL;
}int main() {string name1 = "SelfDetach-1";string name2 = "SelfDetach-2";pthread_t tid1, tid2;pthread_create(&tid1, NULL, thread_routine, &name1);pthread_create(&tid2, NULL, thread_routine, &name2);// 主线程等待6秒,确保新线程执行完毕cout << "主线程:等待新线程执行完毕(6秒)" << endl;sleep(6);cout << "主线程:退出" << endl;return 0;
}
运行结果:
主线程:等待新线程执行完毕(6秒)
线程SelfDetach-1:已自分离,ID = 140704123456768
线程SelfDetach-2:已自分离,ID = 140704115064064
线程SelfDetach-1:执行完毕,自动释放资源
线程SelfDetach-2:执行完毕,自动释放资源
主线程:退出
这种方式的优势在于:线程的分离逻辑由自己控制,比如可以根据参数决定是否分离——更适合复杂场景。
8.4 关键误区:分离后的线程不能pthread_join
如果一个线程已经被分离(无论是主线程分离还是自分离),再调用pthread_join
等待它,会返回错误码EINVAL
(非法参数),因为分离线程不需要也不允许被等待。
示例:验证分离线程pthread_join
失败
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <string.h>using namespace std;void *thread_routine(void *arg) {sleep(2); // 模拟任务return NULL;
}int main() {pthread_t tid;pthread_create(&tid, NULL, thread_routine, NULL);// 分离线程pthread_detach(tid);cout << "主线程:已分离线程,ID = " << tid << endl;// 尝试等待分离线程(预期失败)void *result;int ret = pthread_join(tid, &result);if (ret != 0) {cout << "pthread_join失败:" << strerror(ret) << "(错误码:" << ret << ")" << endl;}return 0;
}
运行结果:
主线程:已分离线程,ID = 140704123456768
pthread_join失败:Invalid argument(错误码:22)
错误码22对应的就是EINVAL
(非法参数)——这验证了“分离线程不能pthread_join
”的规则。
8.5 线程分离的底层原理:修改TCB的状态位
线程分离的本质很简单:pthread
库在TCB中维护了一个“分离状态位”(比如detached
字段),调用pthread_detach
时,只是将这个状态位从“0(可连接)”改成“1(分离)”。
线程终止时,pthread
库会检查TCB的分离状态位:
- 如果是“可连接”(0):保留TCB和返回值,等待
pthread_join
回收; - 如果是“分离”(1):直接销毁TCB、释放栈空间,不需要等待。
也就是说,线程分离并没有改变线程的执行逻辑,只是改变了线程终止后的“资源回收方式”——这也是为什么分离操作如此轻量的原因。
第9章 C++11多线程
前面我们讲的都是Linux原生的pthread
库,但它有个缺点:不跨平台——在Windows上无法使用。C++11标准引入了std::thread
等类,封装了不同操作系统的原生线程接口(Linux上封装pthread
,Windows上封装CreateThread
),实现了“一次编码,多平台运行”。
10.1 C++11多线程的核心优势:跨平台与面向对象
C++11多线程的核心优势有两个:
- 跨平台:不需要修改代码,在Linux、Windows、macOS上都能编译运行;
- 面向对象:用
std::thread
类表示线程,std::mutex
类表示互斥锁,接口更直观,不用记复杂的C风格函数。
10.2 C++11线程的基本使用:std::thread
std::thread
是C++11中表示线程的类,创建线程时只需传递“线程要执行的函数”和“参数”,销毁时自动 join 或 detach(但推荐手动处理)。
10.2.1 创建线程与等待线程
#include <iostream>
#include <thread> // C++11线程头文件
#include <chrono> // 用于std::this_thread::sleep_forusing namespace std;// 线程要执行的函数(无参数)
void thread_func1() {cout << "线程1:开始执行" << endl;// 模拟任务(休眠2秒)this_thread::sleep_for(chrono::seconds(2));cout << "线程1:执行完毕" << endl;
}// 线程要执行的函数(带参数)
void thread_func2(int num, const string& name) {cout << "线程" << name << ":编号 = " << num << ",开始执行" << endl;this_thread::sleep_for(chrono::seconds(1));cout << "线程" << name << ":执行完毕" << endl;
}int main() {// 1. 创建线程1(无参数)thread t1(thread_func1);// 2. 创建线程2(带参数,注意传递引用时要用ref())thread t2(thread_func2, 2, "Thread-2");cout << "主线程:等待子线程执行完毕" << endl;// 等待线程执行完毕(类似pthread_join)t1.join();t2.join();cout << "主线程:所有子线程执行完毕,退出" << endl;return 0;
}
编译运行(注意:Linux下编译需要加-lpthread
,C++11及以上标准):
g++ -o cpp11_thread cpp11_thread.cpp -lpthread -std=c++11
./cpp11_thread
运行结果:
主线程:等待子线程执行完毕
线程1:开始执行
线程Thread-2:编号 = 2,开始执行
线程Thread-2:执行完毕
线程1:执行完毕
主线程:所有子线程执行完毕,退出
关键说明:
std::thread t(func, args...)
:创建线程,func
是线程入口函数,args
是传递给func
的参数;t.join()
:等待线程执行完毕,释放资源(类似pthread_join
);this_thread::sleep_for(chrono::seconds(2))
:当前线程休眠2秒(类似sleep(2)
);- 传递引用参数时,必须用
std::ref(arg)
,否则会默认传递值拷贝。
10.2.2 线程分离:t.detach()
C++11中线程分离用detach()
方法,效果和pthread_detach
一致:线程终止后自动释放资源,不需要join()
。
#include <iostream>
#include <thread>
#include <chrono>using namespace std;void thread_func() {cout << "分离线程:开始执行" << endl;this_thread::sleep_for(chrono::seconds(3));cout << "分离线程:执行完毕,自动释放资源" << endl;
}int main() {thread t(thread_func);t.detach(); // 分离线程cout << "主线程:继续执行自己的任务(5秒)" << endl;this_thread::sleep_for(chrono::seconds(5));cout << "主线程:退出" << endl;return 0;
}
运行结果:
主线程:继续执行自己的任务(5秒)
分离线程:开始执行
分离线程:执行完毕,自动释放资源
主线程:退出
10.3 C++11互斥锁:std::mutex
C++11中的std::mutex
封装了原生互斥锁(Linux下是pthread_mutex_t
),用法和pthread_mutex_t
类似,只是接口更面向对象。
10.3.1 用std::mutex
解决卖票问题
#include <iostream>
#include <vector>
#include <thread>
#include <mutex> // C++11互斥锁头文件
#include <chrono>using namespace std;int tickets = 100;
mutex ticket_mutex; // C++11互斥锁void sell_ticket(const string& name) {while (true) {// 加锁ticket_mutex.lock();if (tickets > 0) {// 模拟卖票耗时this_thread::sleep_for(chrono::milliseconds(1));cout << "线程" << name << ":卖出1张票,剩余 = " << --tickets << endl;// 解锁ticket_mutex.unlock();} else {ticket_mutex.unlock();cout << "线程" << name << ":票已卖完,退出" << endl;break;}// 模拟非临界区操作this_thread::sleep_for(chrono::milliseconds(500));}
}int main() {vector<thread> threads;// 创建3个卖票线程threads.emplace_back(sell_ticket, "Seller-1");threads.emplace_back(sell_ticket, "Seller-2");threads.emplace_back(sell_ticket, "Seller-3");// 等待所有线程for (auto& t : threads) {t.join();}cout << "最终剩余票数 = " << tickets << endl;return 0;
}
运行结果和pthread
版本一致,没有负数问题——std::mutex
的lock()
和unlock()
方法对应pthread_mutex_lock
和pthread_mutex_unlock
。
10.3.2 更安全的互斥锁:std::lock_guard
(自动解锁)
std::mutex
的lock()
和unlock()
需要手动配对,一旦忘记解锁就会导致死锁。C++11提供了std::lock_guard
,它是一个“智能锁”——构造时自动加锁,析构时自动解锁,完全避免了手动解锁的遗漏。
示例:用std::lock_guard
优化卖票代码
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>using namespace std;int tickets = 100;
mutex ticket_mutex;void sell_ticket(const string& name) {while (true) {// lock_guard构造时自动加锁,析构时自动解锁lock_guard<mutex> lock(ticket_mutex);if (tickets > 0) {this_thread::sleep_for(chrono::milliseconds(1));cout << "线程" << name << ":卖出1张票,剩余 = " << --tickets << endl;} else {cout << "线程" << name << ":票已卖完,退出" << endl;break;}// 离开if/else块时,lock_guard析构,自动解锁}
}int main() {thread t1(sell_ticket, "Seller-1");thread t2(sell_ticket, "Seller-2");t1.join();t2.join();cout << "最终剩余票数 = " << tickets << endl;return 0;
}
关键优势:
- 不需要手动调用
unlock()
,lock_guard
在离开作用域(比如if
块、while
块)时会自动析构,解锁互斥锁; - 即使线程在临界区中
return
或抛出异常,lock_guard
也会析构解锁,避免死锁。
std::lock_guard
是C++11中推荐的互斥锁使用方式,比手动lock()
/unlock()
更安全。
10.4 C++11多线程与pthread
的关系
C++11多线程并不是“全新的线程实现”,而是对原生线程库的封装:
- 在Linux上,
std::thread
底层调用pthread_create
,std::mutex
底层调用pthread_mutex_init
等函数; - 在Windows上,
std::thread
底层调用CreateThread
,std::mutex
底层调用InitializeCriticalSection
等函数。
这种封装的好处是“屏蔽平台差异”——你写的C++11多线程代码,在Linux和Windows上都能编译运行,不需要修改;坏处是“性能略有损耗”——因为多了一层封装,但对大部分应用来说,这种损耗可以忽略不计。
10.5 选择建议:什么时候用pthread
,什么时候用C++11多线程?
- 只在Linux平台运行(比如服务器程序):可以用
pthread
,性能略高,且能直接使用Linux特有的线程属性; - 需要跨平台(比如桌面应用):必须用C++11多线程,避免重复编码;
- C++项目:优先用C++11多线程,接口更面向对象,
std::lock_guard
等智能锁更安全; - C项目:只能用
pthread
,因为C语言没有原生的多线程支持。
总结和避坑
- 资源传递用堆:线程参数和返回值优先用
new/malloc
分配,避免栈变量被覆盖; - 锁要配对:手动加锁后必须解锁,优先用
std::lock_guard
(C++11)或pthread_cleanup_push
(C)避免死锁; - 临界区最小化:只把共享资源访问放进临界区,减少线程等待时间;
- 主线程最后退:用
join
或sleep
确保主线程比所有子线程后退出; - 避免共享资源:能不用全局变量就不用,用TLS或参数传递替代,从源头减少线程安全问题;
- 调试用工具:遇到线程问题时,用
pstack
(查看线程调用栈)、htop
(查看线程CPU占用)、valgrind
(检测内存泄漏和竞争)。