Linux 多线程编程:线程栈、TLS、互斥锁与条件变量
Linux 多线程编程涉及线程栈隔离、线程局部存储(TLS)、互斥锁及条件变量。线程栈为私有资源,确保局部变量独立;全局数据共享需通过__thread 实现线程私有化。互斥锁解决并发访问共享数据的安全问题,防止竞态条件。条件变量用于线程同步,协调执行顺序,避免忙等待。通过抢票案例演示了加锁机制与饥饿现象,并介绍了 pthread 库相关 API 的使用方法及底层原子交换原理。

Linux 多线程编程涉及线程栈隔离、线程局部存储(TLS)、互斥锁及条件变量。线程栈为私有资源,确保局部变量独立;全局数据共享需通过__thread 实现线程私有化。互斥锁解决并发访问共享数据的安全问题,防止竞态条件。条件变量用于线程同步,协调执行顺序,避免忙等待。通过抢票案例演示了加锁机制与饥饿现象,并介绍了 pthread 库相关 API 的使用方法及底层原子交换原理。


微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
在 Linux 系统中,没有很明确的线程的概念,只有轻量级进程的概念,所以操作系统提供的系统调用并不能直接创建线程,只能创建轻量级进程。这个系统调用接口就是 clone。
pthread 是一个用户态的第三方库,程序中通过动态链接的方式加载 pthread 库。因为 pthread 是共享库,它会被加载到进程虚拟地址空间的共享区。
除了主线程之外,主线程创建的其他线程都需要独立的栈空间。在线程栈中可以保存函数调用过程中的局部变量、返回值地址以及临时数据。如果所有创建的新线程都共享同一个栈空间,很容易造成数据覆盖等问题。因此线程栈是线程私有的资源,而其他代码区、数据区以及堆空间都是共享的。pthread 维护的线程栈最后会通过系统调用 clone 中的第二个参数 child_stack 传递给内核,从而成功创建线程并保证线程栈的独立性。
以下是一个简单的多线程程序示例:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
struct threadDate {
std::string threadname;
};
void *threadRoutine(void *args) {
threadDate *td = (threadDate *)args;
int count = 5;
while (count--) {
printf("pid:%d, tid:%lx, threadname:%s\n", getpid(), pthread_self(), td->threadname.c_str());
sleep(1);
}
return nullptr;
}
int main() {
std::vector<pthread_t> tids;
for (int i = 0; i < 5; i++) {
pthread_t tid;
threadDate *td = new threadDate;
td->threadname = "thread-" + std::to_string(i);
pthread_create(&tid, nullptr, threadRoutine, td);
tids.push_back(tid);
}
for (int i = 0; i < 5; i++) {
pthread_join(tids[i], nullptr);
}
return 0;
}
为了验证每个线程是否有独立的栈结构,可以在线程函数中创建局部变量 test:
void *threadRoutine(void *args) {
int test = 1;
threadDate *td = (threadDate *)args;
int count = 5;
while (count--) {
printf("pid:%d, tid:%lx, threadname:%s, test:%d , &test:%p\n", getpid(), pthread_self(), td->threadname.c_str(), test, &test);
test++;
sleep(1);
}
return nullptr;
}
结果显示,每一个线程中的局部变量 test 都是从 1 开始的,并且每一个 test 的地址都是不一样的,证明了每一个线程都有一个独立的线程栈,线程栈是互不干扰的。
对于全局数据,每一个线程都是可以访问的,并且全局数据对每一个线程都是共享的。例如定义全局变量 value:
int value = 10;
void *threadRoutine(void *args) {
threadDate *td = (threadDate *)args;
int count = 5;
while (count--) {
printf("pid:%d, tid:%lx, threadname:%s, value:%d , &value:%p\n", getpid(), pthread_self(), td->threadname.c_str(), value, &value);
sleep(1);
}
return nullptr;
}
可以看到每一个线程都可以访问到这个全局变量 value,并且访问的 value 都是同一个地址。
但是局部变量的生命周期只在函数的作用域内,而全局变量所有线程共享。有没有一种既是全局的,又独属于线程的变量呢?这就是 __thread,GCC 提供的线程局部存储机制。每个线程拥有独立的变量副本,使全局变量在线程间互不干扰。只需要在定义全局变量前加 __thread 即可。
__thread int value = 10;
这样每个线程都拥有了一个独属于自己的全局变量。
当创建新的线程之后,主线程通常需要调用 pthread_join 函数进行阻塞式等待创建的新的线程结束,这会导致主线程无法正常的工作。如果创建新线程后不关心它的退出结果,只要它完成任务之后自动释放线程资源就好了,这样主线程就可以继续往下执行了。这就是线程分离。
可以通过 pthread_detach 函数将线程设置为分离状态:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
struct threadDate {
std::string threadname;
};
__thread int value = 10;
void *threadRoutine(void *args) {
pthread_detach(pthread_self());
threadDate *td = (threadDate *)args;
int count = 5;
while (count--) {
printf("pid:%d, tid:%lx, threadname:%s, value:%d , &value:%p\n", getpid(), pthread_self(), td->threadname.c_str(), value, &value);
sleep(1);
}
return nullptr;
}
int main() {
std::vector<pthread_t> tids;
for (int i = 0; i < 3; i++) {
pthread_t tid;
threadDate *td = new threadDate;
td->threadname = "thread-" + std::to_string(i);
pthread_create(&tid, nullptr, threadRoutine, td);
tids.(tid);
}
( i = ; i < ; i++) {
n = (tids[i], );
(n == ) {
();
} {
();
}
}
;
}
可以看到,由于对每一个线程都进行了分离,主线程不需要再等待新线程,只需要执行自己的任务即可。当主线程执行结束之后,整个程序也就终止了。因此进行线程分离的时候,要保证主线程在新线程之后退出,不然就等不到新线程运行结束了。
大部分情况下,线程使用的数据都属于局部变量,变量的地址在线程栈空间,其他线程很难获取到这种变量。但是有时候,很多变量都需要线程间共享。虽然通过共享可以很简单地完成线程间的交互,但是当多个线程并发操作的时候,难免就会带来一些问题。
以多线程抢票程序为例:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
int tickets = 100;
class threadDate {
public:
threadDate(int num) { threadname = "thread-" + std::to_string(num); }
public:
std::string threadname;
};
void *getTicket(void *args) {
threadDate *td = (threadDate *)args;
while (1) {
if (tickets > 0) {
usleep(1000);
printf("%s get a tickets , tickets : %d\n", td->threadname.c_str(), tickets);
tickets--;
} else {
break;
}
}
return nullptr;
}
int main() {
std::vector<pthread_t> tids;
std::vector<threadDate *> thread_datas;
for (int i = 1; i <= 3; i++) {
pthread_t tid;
threadDate *td = (i);
thread_datas.(td);
(&tid, , getTicket, td);
tids.(tid);
}
( i = ; i < ; i++) {
(tids[i], );
}
( thread : thread_datas) {
thread;
}
;
}
可能会出现 tickets 的值变为 -1 的情况,并且不同的线程抢到同一张票。这是因为多线程中对一个共享变量进行 ++/-- 的操作是不安全的。对一个数进行减一的操作分为以下步骤:
用汇编语言表示 tickets-- 这一条语句就被分为:
mov eax, [x] add eax, 1 mov [x], eax
换言之就是一条语句的执行会被分为三部分。假如现在有一个线程正在运行,tickets 的值为 100,并且刚刚将 tickets 保存到 CPU 中的寄存器之后,由于线程发生切换,这个时候线程就将自己的上下文数据保存到 task_struct 中,然后换成一个线程上 CPU 继续运行。而这个线程完整的执行完这三步,成功将 tickets 的值改为了 99,并保存到内存中。这个时候又发生了线程切换,再次将之前的线程切换到 CPU 之后,线程将自己 task_struct 中保存的线程上下文数据恢复到 CPU 上,这个时候寄存器依旧保存的值是 100,继续往下执行后,又将这个值减 99,再次保存到内存中。这就是为什么上面我们看到多个线程抢到了同一张票,那为什么最后这个值会变为 -1 呢?其实也很好理解,当 ticket 的值为 1 时,我们在执行 tickets 是否大于 0 的时候,一个进程刚刚判断完 tickets 的值为 1,可以进入这个判断语句之后,发生了线程切换,另一个线程切换上来之后,依旧判断 tickets 的值为 1,当这个线程执行完 tickets-- 之后,将这个 tickets 的值改为 0,另一个线程再次被切换之后,这个线程又再次执行了 tickets--,所以这个时候 tickets 的值就变为了 -1。这就是多线程对共享数据访问时的弊端。
那么我们应该如何解决这些情况的发生呢?这就要求我们必须做到对共享数据进行访问的时候,必须保证只有一个执行流进行访问。也就是保证一个线程在执行 tickets-- 的时候,另外的线程只有等待,等待这个线程执行结束之后,其它的线程才可以对其进行访问。这个办法就是——加锁。
我们先来看看 pthread 库给我们提供的函数调用接口,然后利用函数调用接口成功解决上面的问题。
用于初始化一个互斥锁,在使用互斥锁之前必须先调用该函数。
用于加锁,当多个线程竞争同一把互斥锁时,只有一个线程可以成功获得锁,其余线程会被阻塞。
用于解锁,释放当前线程持有的互斥锁,使其他阻塞线程有机会继续执行。
用于销毁互斥锁,释放与互斥锁相关的系统资源。
void *getTicket(void *args) {
threadDate *td = (threadDate *)args;
while (1) {
if (tickets > 0) {
usleep(1000);
printf("%s get a tickets , tickets : %d\n", td->threadname.c_str(), tickets);
tickets--;
} else {
break;
}
}
return nullptr;
}
那么现在我们需不需要将这个函数整个都加上锁呢,其实是不需要的,因为在这个函数中不是每一个部分都是共享资源。另外从实质上讲,加锁其实就是用时间换安全的手段。这是因为我们的线程是并发访问的,而并发访问就很容易造成上面这种数据不一致的现象,所以为了安全,我们必须让线程在访问共享资源时进行串行访问(也就是执行临界区的代码时串行执行),也就是每次只允许一个线程进行 tickets>0 的判断和 tickets-- 的操作,这样就保证了线程不会出现数据不一致的问题,但是这样就会导致线程的执行速率有所下降,所以加锁其实就是用时间换安全,所以为了尽可能的降低效率的减少,我们需要尽可能的缩小加锁的范围,因此我们的临界区域要尽可能的小。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
int tickets = 100;
class threadDate {
public:
threadDate(int num, pthread_mutex_t *mutex) : mutex_(mutex) { threadname = "thread-" + std::to_string(num); }
public:
std::string threadname;
pthread_mutex_t *mutex_;
};
void *getTicket(void *args) {
threadDate *td = (threadDate *)args;
while (1) {
pthread_mutex_lock(td->mutex_);
if (tickets > 0) {
usleep(1000);
printf("%s get a tickets , tickets : %d\n", td->threadname.c_str(), tickets);
tickets--;
pthread_mutex_unlock(td->mutex_);
} else {
pthread_mutex_unlock(td->mutex_);
break;
}
}
return nullptr;
}
int main {
mutex;
(&mutex, );
std::vector<> tids;
std::vector<threadDate *> thread_datas;
( i = ; i <= ; i++) {
tid;
threadDate *td = (i, &mutex);
thread_datas.(td);
(&tid, , getTicket, td);
tids.(tid);
}
( i = ; i < ; i++) {
(tids[i], );
}
( thread : thread_datas) {
thread;
}
;
}
现在可以看到加锁之后,我们的程序终于得到了正确的结果,不再出现票数变为 0 的情况。但是仔细的同学可能就发现一个问题了,就是这所有的票都被一个线程抢走了,都没有其他线程的事,这是怎么回事呢?其实这是因为线程对锁的竞争能力不同。在这句话的意思其实很好理解,现在 CPU 上正在运行一个线程,这个线程释放了锁之后,由于这个线程的时间片还没有到,这个线程又继续执行,当它继续执行的时候,其它的线程还处于挂起状态,而这个线程处于运行态,并且又再次申请锁,因此锁又被这个线程拿到了,这样就导致这个线程刚释放锁资源之后又占用了锁,所以其它的线程根本就拿不到锁,也就进入不了临界区,所以会出现上面这种一个线程拿走所有票的情况。
这种情况一看就是不对的,理应就是当这个正在运行的线程释放锁之后,不能够重新申请锁,必须排到队列的尾部。并且让所有的线程获取锁时,按照一定的顺序进行获取,这也就是——同步。
现在还有一个问题就是当所有的线程都进行申请锁的时候,这个时候锁也就成为了共享资源,那么如何保证线程访问锁时,锁处于安全呢?这个其实很简单就是在我们申请锁和释放锁的操作时将其设置为原子性操作即可。那么这个锁到底是怎么实现的呢?我们接着往下看。
经过上面的例子,我们现在已经明白了我们在编辑器中写的每一条代码,其实在底层都会被分解为几条机器指令,而我们得 CPU 在执行机器指令得时候就是原子性的,就是这条机器指令要么就不执行,要执行就一定会执行完毕。因此为了实现锁,大多数的 CPU 架构提供了 swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。
加锁和解锁就是通过这样的操作保证原子性的,现在我们将 mutex 的值认为是 1,加锁时将 ax 寄存器中的值初始化为 0,然后将 ax 寄存器的值和内存中 mutex 的值进行交换,如果 ax 的寄存器的值大于 0,也就是 ax 寄存器的值和内存中的 mutex 的值成功交换,也就是成功获得了锁,这个线程就可以进入临界区。当这个 ax 的寄存器的值不大于 0 时,表明这个时候已经有线程获取到了这个锁,这个锁就需要被挂起。
假设我们有一个 mutex,初始值为 1。加锁的过程可以理解为:
为什么这种方式能保证原子性?假设有两个线程同时尝试加锁:
通过这个机制,无论线程切换如何发生,原子交换保证了同一时刻只有一个线程能获取到锁,从而保证了临界区的互斥性。
线程同步就是在保证数据安全的情况下,让我们的线程访问资源时具有一定的顺序性。
这是什么意思呢?就是上面多线程抢票的问题,当正在持有锁的线程执行的时候,等待锁资源的线程必须排队等待,不可以像我们围在一起抢电脑玩一样,谁先抢上就是谁的。线程同步要求等待锁的线程一定是具有顺序性的,这样内核在唤醒等待锁线程中的一个就可以了,而不会将全部的线程都唤醒,因为将全部的线程都唤醒之后,CPU 就会白白的浪费,得不偿失,所以锁释放之后,只会唤醒一个等待的线程,用最小的唤醒代价换取最高的并发效率。
那么我们如何将我们上面那种混乱抢票的程序修改为有一定顺序性的访问锁资源呢?这个办法就是条件变量。
这个条件变量会维护一个等待队列,当线程申请一个已经被申请走的锁资源时,这个线程就会调用条件变量中的等待接口,将自己加入到这个等待队列中,此时这个线程就会处于挂起状态。如果持有锁的进程释放锁之后,它会通知条件变量,自己已经将这个锁释放掉了,这个时候,条件变量就会在等待队列中唤醒一个等待的线程,这个时候这个被唤醒的锁再次申请锁时就可以获得该锁,继续向下执行临界代码,通过这种方式,线程不会在锁被占用时无意义地反复竞争,而是通过条件变量进入睡眠状态,提高了系统的整体效率。
唤醒等待在 cond 上的一个线程。
唤醒等待在 cond 上的多个线程。
销毁条件变量 cond,释放系统资源。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <string>
#include <vector>
int tickets = 100;
class threadDate {
public:
threadDate(int num, pthread_mutex_t *mutex, pthread_cond_t *cond) : mutex_(mutex), cond_(cond) { threadname = "thread-" + std::to_string(num); }
public:
std::string threadname;
pthread_mutex_t *mutex_;
pthread_cond_t *cond_;
};
void *getTicket(void *args) {
pthread_detach(pthread_self());
threadDate *td = (threadDate *)args;
while (1) {
pthread_mutex_lock(td->mutex_);
pthread_cond_wait(td->cond_, td->mutex_);
if (tickets > 0) {
usleep(1000);
printf("%s get a tickets , tickets : %d\n", td->threadname.c_str(), tickets);
tickets--;
pthread_mutex_unlock(td->mutex_);
} else {
(td->mutex_);
;
}
}
;
}
{
mutex;
cond;
(&mutex, );
(&cond, );
std::vector<> tids;
std::vector<threadDate *> thread_datas;
( i = ; i <= ; i++) {
tid;
threadDate *td = (i, &mutex, &cond);
thread_datas.(td);
(&tid, , getTicket, td);
tids.(tid);
}
() {
(&cond);
}
( thread : thread_datas) {
thread;
}
;
}
通过这样的方式我们就可以让所有的线程都抢到票,不会让一个线程一直可以抢到票。但是条件变量的用法并不是这样的(有一定的错误,这只是让大家看到所有的线程都可以抢到票了)。在实际应用中,通常结合生产者消费者模型来正确使用条件变量。