线程私有栈
所有线程都有自己的独立的栈
主线程用的是进程系统栈,新线程用的是库中提供的栈
在多线程编程中,每个线程都需要独立的执行环境和函数调用栈。因此,操作系统为每个线程分配独立的线程栈,用于存储线程的局部变量、函数调用信息和其他线程执行所需的上下文信息。
每个线程都有独立的栈的原因:
-
线程隔离:每个线程都需要独立的执行环境和函数调用栈,以便于并发执行和独立管理线程的状态。通过为每个线程分配独立的栈空间,可以确保线程之间的数据和函数调用不会相互干扰,从而实现线程的隔离性。
-
函数调用和局部变量:栈是函数调用的核心机制之一。每当一个函数被调用时,相关的函数参数、局部变量和返回地址等信息都需要被保存在栈中。由于每个线程可能同时执行多个函数调用,因此每个线程都需要有自己的栈空间来存储这些函数调用的上下文信息。
-
并发执行:多线程编程的一个主要目的是实现并发执行,从而提高程序的性能和响应能力。如果多个线程共享同一个栈,会导致线程之间相互干扰,无法独立执行和管理函数调用。通过为每个线程分配独立的栈空间,可以实现线程之间的并发执行,提高程序的并发性能。
-
栈的大小控制:每个线程的栈空间大小可以根据线程的需求进行设置。不同的线程可能需要不同大小的栈空间,以适应其函数调用深度、局部变量的大小等。通过为每个线程分配独立的栈,可以根据线程的需求进行灵活的大小设置,以避免栈溢出或浪费内存的情况。
每个线程都有一个独立的栈是为了实现线程的隔离性、函数调用的独立管理、并发执行和栈大小的灵活控制等目的。这样可以确保线程之间的数据和函数调用不会相互干扰,同时实现高效的并发执行和资源管理。
线程中的局部变量
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
void *thread(void* args)
{
const char* name = (const char*)args;
int cnt = 5;
while(cnt--)
{
sleep(1);
cout << "线程名:" << name << ",&cnt: " << &cnt << ", 线程id: " << pthread_self() << endl;
cout << "&name" << (int64_t)(&name) << endl;
}
return nullptr;
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, thread, (void*)"线程一");
pthread_create(&t2, nullptr, thread, (void*)"线程二");
cout << "主线程, 新线程id:" << t1 << endl;
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return 0;
}
我们创建了两个线程,两个线程都执行thread函数,那么这个函数中创建的局部变量name和cnt是两个线程都有,还是两个线程共用?
我们可以看到两个线程打印的两个局部变量的地址都不一样,也就是说两个线程不是共有这两个局部变量,而是两个线程分别拥有的
这是因为线程是由私有栈的,这个方法被线程调用之后会把用局部变量压入线程自己的栈中存放
线程中的全局变量
我们来看看全局变量
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int a = 100;
void *thread(void* args)
{
const char* name = (const char*)args;
int cnt = 5;
while(cnt--)
{
sleep(1);
cout << "name: " << name << " a: " << a++ << " &a:" << &a << endl;
}
return nullptr;
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, thread, (void*)"线程一");
pthread_create(&t2, nullptr, thread, (void*)"线程二");
cout << "主线程, 新线程id:" << t1 << endl;
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return 0;
}
可以看到两个线程打印出来的a的地址都是一样的,也就是说这个全局变量是每个线程共享的
我们修改一下代码
__thread int a = 100;
在int前加了一个__thread
,这样可以让这个全局变量存入每个线程的局部存储区域,让每个线程都有一个这个变量
可以看到在每个线程中变量的地址是不一样的
线程互斥
在多线程编程中,线程的互斥重要的概念,用于解决多个线程之间共享资源时可能出现的竞态条件和数据不一致性的问题。
线程互斥(Thread Mutual Exclusion)是线程同步的一种常见方式,它通过互斥锁(Mutex)来实现。互斥锁是一种同步原语,用于保护共享资源,一次只允许一个线程访问被保护资源,其他线程需要等待锁的释放才能继续执行。当一个线程获得了互斥锁后,其他线程就无法获得该锁,只能等待。
像在代码中定义一个全局变量,这就是共享资源,那么所有线程都可以访问到
如果每个线程都对这个全局变量修改,比如说对它a--
,a的值原先定义为100,所有线程都死循环对执行a--
这条代码在汇编中是三条语句,把a的数据从内存加载到CPU,然后在CPU中对a减等一,然后再把a减等1之后的数据加载回内存
对于这个共享资源没有被保护的话,可能一个线程在执行这三条汇编指令的其中一条的时候,另一个进程突然过来也要执行这三条汇编指令
比如现在a的值是减到了80,准备将a的值加载回内存,然后保护现场,退出让另一个线程执行,另一个线程看到a值为81,然后继续减,减到40,准备将a的值40加载到内存中,原先把a减到80的线程又回来了,然后它继续执行上次没有执行完的语句,然后a的值又变成了80
上述这个例子就是可能会发生的并发访问导致数据不一致的问题
对共享资源保护,就是临界资源,访问临界资源的代码叫做临界区,没有访问临界资源的代码就是非临界区
我们想让多个线程安全的访问临界资源,就可以通过加锁,来互斥访问,这样像执行a--
这样的指令,在汇编上是三条语句,那么执行语句每次都会执行完整才能切换线程,这就是原子性
原子性(Atomicity)是指一个操作或一系列操作要么全部执行成功,要么全部不执行,中间不会被其他线程中断或干扰。
我们来模拟抢票来看一下多线程并发访问导致数据不一致的问题
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <cstring>
#include <cstdio>
using namespace std;
int ticket = 1000;
void *thread(void* args)
{
const char* name = (const char*)args;
while(true)
{
if(ticket > 0)
{
usleep(2000);//抢票花费的时间
cout << name << ": 抢到了票, 票编号:" << ticket-- << endl;
}
else
{
break;
}
usleep(20);//抢完票后续动作
}
return nullptr;
}
int main()
{
pthread_t t[4];
for(int i = 0; i < 4; i++)
{
char* data = new char[64];
snprintf(data, sizeof(data), "线程%d", i);
pthread_create(t + i, nullptr, thread, (void*)data);
}
for(int i = 0; i < 4; i++)
{
pthread_join(t[i], nullptr);
}
return 0;
}
可以看到,抢到的票数有负数,按我们的代码逻辑最多是到0的
我们来分析一下,首先临界资源是ticket
,临界区是if(ticket > 0)
和cout << name << ": 抢到了票, 票编号:" << ticket-- << endl;
现在ticket
的值为1,然后线程a通过临界区if(ticket > 0)
进来了,执行usleep(2000);//抢票花费的时间
之后线程b也来执行临界区if(ticket > 0)
,这时ticket
的值还是1,也进来了,执行usleep(2000);//抢票花费的时间
再然后线程c也是和线程a,b一样进来了,它们三个线程都会去执行临界区cout << name << ": 抢到了票, 票编号:" << ticket-- << endl;
,这样就打印出来了0,-1,-2
线程的同步和互斥可以通过以下几种常见的机制来实现:
-
互斥锁(Mutex):使用互斥锁可以确保在任意时刻只有一个线程可以获得锁,其他线程需要等待。当线程完成对共享资源的访问后,释放互斥锁,其他线程才能获得锁并继续执行。
-
信号量(Semaphore):信号量是一个计数器,用于控制同时访问某个共享资源的线程数量。当线程要访问共享资源时,需要先尝试获取信号量,如果信号量的计数器大于0,表示资源可用,线程可以继续执行;如果计数器为0,表示资源已被占用,线程需要等待其他线程释放资源后才能继续执行。
-
条件变量(Condition Variable):条件变量用于在线程之间建立一种等待-通知机制,用于线程的协调和同步。一个线程可以等待某个条件变量的满足,而另一个线程可以在满足条件时发送通知,唤醒等待的线程继续执行。
-
读写锁(Read-Write Lock):读写锁用于在读操作和写操作之间实现高效的同步。多个线程可以同时获取读锁,但只有一个线程可以获取写锁。这样可以实现读操作的并发性,但保证写操作的原子性和独占性。
互斥锁mutex
#include <pthread.h>
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_init
是 POSIX 线程库中用于初始化互斥锁(mutex)的函数。它的原型如下:
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
pthread_mutex_init
函数:
mutex
:指向互斥锁变量的指针,用于存储初始化后的互斥锁对象。attr
:指向pthread_mutexattr_t
类型的指针,用于指定互斥锁的属性。可以传入NULL
,表示使用默认属性。
-
初始化:在使用互斥锁之前,必须先对其进行初始化。可以使用
pthread_mutex_init
函数进行初始化,该函数有两种常用的方式:-
静态初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
PTHREAD_MUTEX_INITIALIZER
: 这是一个宏常量,用于初始化互斥锁。它会将互斥锁初始化为一个静态分配的锁。
静态初始化的互斥锁可以直接使用,无需再调用pthread_mutex_init
函数进行初始化,也无需调用pthread_mutex_destroy
函数去销毁,因为静态初始化的互斥锁是不可销毁的。它们会在程序终止时自动释放相关资源。这种方式适用于全局的互斥锁变量 -
动态初始化:
pthread_mutex_t mutex; pthread_mutex_init(&mutex, NULL);
这种方式会在运行时动态地初始化互斥锁。与静态初始化不同,需要调用
pthread_mutex_destroy
函数销毁,动态初始化会在堆上分配内存来存储互斥锁对象,并初始化其属性。
-
-
使用:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutex_lock
是 POSIX 线程库中用于加锁互斥锁(mutex)的函数。它的原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
pthread_mutex_lock
:
-
参数:
mutex
:指向互斥锁变量的指针,指定要加锁的互斥锁对象。
-
加锁操作:
pthread_mutex_lock
函数用于获取互斥锁的锁定状态。如果互斥锁当前没有被其他线程持有,调用该函数会成功获取互斥锁并立即返回。如果互斥锁已经被其他线程持有,则调用线程将被阻塞,直到互斥锁被释放。 -
阻塞和解除阻塞:如果互斥锁当前处于不可获取状态(已被其他线程锁定),则调用线程将被阻塞,当调用线程被阻塞在
pthread_mutex_lock
函数时,它会等待互斥锁的释放。一旦互斥锁被释放,调用线程将获得互斥锁的锁定状态,并继续执行后续代码。这样可以确保在任何给定时刻只有一个线程能够访问被互斥锁保护的共享资源。 -
死锁:如果同一个线程多次调用
pthread_mutex_lock
函数尝试获取同一个互斥锁,并且在获取互斥锁之前没有释放它,则会发生死锁。因此,使用互斥锁时应该避免死锁情况的发生。
我们来对刚刚的抢票的代码加上锁
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
int ticket = 1000;
pthread_mutex_t mutex;
void *thread(void* args)
{
const char* name = (const char*)args;
while(true)
{
pthread_mutex_lock(&mutex);
if(ticket > 0)
{
usleep(2000);//抢票花费的时间
cout << name << ": 抢到了票, 票编号:" << ticket-- << endl;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
usleep(20);//抢完票后续动作
}
return nullptr;
}
int main()
{
pthread_t t[4];
for(int i = 0; i < 4; i++)
{
char* data = new char[64];
snprintf(data, sizeof(data), "线程%d", i);
pthread_create(t + i, nullptr, thread, (void*)data);
}
for(int i = 0; i < 4; i++)
{
pthread_join(t[i], nullptr);
}
pthread_mutex_destroy(&mutex);
return 0;
}
使用互斥锁的注意事项:
-
凡是访问同一个临界资源,都要进行加锁保护,而且必须加同一把锁
-
每一个线程访问临界区之前,要给临界区加锁,被加锁的临界区代码越少越好,代码能不放临界区就不放临界区
-
线程访问临界区前,每个线程都要看到同一把锁,那么锁也是共享资源,用锁保护共享资源,那锁是安全的吗?锁是安全的,有以下几点原因:
互斥锁的安全性主要通过以下机制来保证:-
原子性操作: 互斥锁的获取和释放操作是原子性的,这意味着它们要么完全执行,要么完全不执行。这确保了在多线程环境中,一个线程成功获取锁之后,其他线程无法同时获取锁。
-
互斥性: 互斥锁是一种保护临界资源的手段,确保在任何时刻只有一个线程可以访问这些资源。当一个线程成功获得互斥锁时,其他线程需要等待,从而避免了并发访问引发的竞争条件和数据不一致性。
-
可重入性: 互斥锁通常支持可重入,即同一个线程可以多次获得同一个锁,而不会发生死锁。这是通过为每个锁分配一个拥有者标识,使线程能够判断是否已经持有锁来实现的。
-
死锁避免: 为了防止死锁,程序员应该小心地设计锁的获取顺序,避免循环等待条件。此外,一些锁实现提供了死锁检测机制,当检测到死锁时,系统可以采取措施解除死锁。
-
适当的锁粒度:锁的粒度应该适当,不应该过于粗粒度,否则可能导致性能问题,也不应该过于细粒度,否则可能引入过多的锁竞争。选择适当的锁粒度有助于提高并发性能。
-
公平性:互斥锁可以是公平的或非公平的。公平锁保证线程按照请求的顺序获得锁资源,避免某些线程长期等待的饥饿现象。非公平锁则允许新请求的线程抢占已经释放的锁。公平性可以确保资源分配的公平性,但可能会影响整体性能。
-
-
在线程加锁完执行临界区的代码的时候,CPU还是可以切换去调度其他进程,那是否可能发生多个线程同时执行临界区的代码并发访问导致数据不一致的问题呢?->不会的,线程已经加锁了,就算切换线程,其他线程也不能访问临界区,因为无法申请到锁
-
有了锁之后,多线程访问临界区就是串行访问
互斥锁的实现原理
执行加锁pthread_mutex_lock
就是先在CPU中寄存器al中写入0,然后xchgb %al, mutex
就是交换mutex的值和al的值,就是加锁,1就代表锁,交换完之后内存中mutex值为0,CPU中al寄存器值为1,然后加完锁执行if,然后return 0
如果锁已经被别的线程拿到了的情况下执行pthread_mutex_lock
,首先还是在CPU中寄存器al中写入0,然后xchgb %al, mutex
交换mutex和CPU中al寄存器的值,锁已经被其他线程获取了,内存中mutex的值为0,交换完之后都是0,走else,挂起等待
执行解锁pthread_mutex_unlock
就是将1写入mutex,将锁还回去,然后唤醒等待锁的线程,被唤醒的线程就可以加锁
死锁
死锁是指系统中的多个进程或线程因为竞争资源而陷入互相等待的状态,导致它们无法继续执行下去。在死锁状态下,每个进程都在等待其他进程释放资源,从而形成了循环等待的局面。以下是死锁发生的必要条件:
-
互斥条件(Mutual Exclusion):至少有一个资源只能被一个进程或线程占用,当一个进程或线程持有该资源时,其他进程或线程无法访问。
-
请求与保持条件(Hold and Wait):进程或线程在等待其他资源的同时,仍然保持它所占有的资源。
-
不可抢占条件(No Preemption):资源不能被强制从一个进程或线程中剥夺,只能在其主动释放后才能被其他进程或线程获取。
-
环路等待条件(Circular Wait):存在一个进程或线程的资源请求序列形成一个环路,使得每个进程或线程都在等待下一个进程或线程所持有的资源。
当以上四个条件同时满足时,就会导致死锁的发生。死锁可能导致系统停滞,无法继续正常运行。
破坏四个必要条件的其中一个就可以避免死锁
解锁死锁的方案:
- 不加锁,就是不要互斥
- 主动释放锁
- 按顺序申请锁
- 控制线程统一释放锁
线程同步
竞争条件(Race Condition):竞争条件是指多个线程同时访问共享资源,由于执行的时序不确定,可能导致程序出现不正确的结果。
线程同步(Thread Synchronization)指多个线程之间协调和控制彼此的执行顺序以及对共享资源的访问,以避免数据竞争和不确定性的结果。线程同步的目的是确保线程之间的协作和资源共享的正确性和一致性。
如果一个线程加锁之后执行临界区的代码,然后释放锁又立马去加锁然后又去执行临界区代码,这样一直的循环,这样就会导致饥饿问题,只有这一个线程可以加锁执行临界区代码,其他线程都阻塞在那里
所以线程同步就很重要,让多线程访问具有一定的顺序,让多线程协同的运行,比如让那个线程释放锁之后就加入等待队列的末尾,而不是队头
条件变量
条件变量(Condition Variable),用于线程间的协作和同步。它允许线程在满足特定条件之前等待,并在条件满足时被其他线程通知。
条件变量就是一个等待队列,让上述场景中的我们就可以让一直循环获取锁,释放锁的线程进入等待队列,让其他线程可以去获取锁
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//全局初始化条件变量和互斥锁一样
int pthread_cond_destroy(pthread_cond_t* cond);
pthread_cond_init
:
- 参数:
cond
:指向pthread_cond_t
类型的指针,表示要初始化的条件变量。attr
:指向pthread_condattr_t
类型的指针,表示条件变量的属性。通常使用默认属性,可以将该参数设置为NULL
。
int pthread_cond_signal(pthread_cond_t* cond);
pthread_cond_signal
:
-
概念:
pthread_cond_signal
函数用于向等待在条件变量上的一个线程发送信号,通知它条件已满足。条件变量是线程间同步和通信的一种机制,用于等待和通知。 -
参数:
cond
:指向pthread_cond_t
类型的指针,表示要发送信号的条件变量。
-
注意事项:
pthread_cond_signal
函数只会唤醒等待在条件变量上的一个线程。如果有多个线程等待在条件变量上,只会有其中一个线程被唤醒,其他线程继续等待。- 调用
pthread_cond_signal
函数并不会立即切换到被唤醒的线程,而是在互斥锁被释放后,被唤醒的线程才有机会获得互斥锁并继续执行。 - 发送信号时,条件变量的互斥锁必须是锁定状态。在发送信号前,应先获得互斥锁,并在发送信号后释放互斥锁。
- 发送信号后,等待在条件变量上的线程应重新检查条件,以确保条件已满足。因为在多线程环境中,发送信号后,可能会有其他线程修改条件。
- 如果没有等待在条件变量上的线程,调用
pthread_cond_signal
函数也不会产生任何效果。
int pthread_cond_broadcast(pthread_cond_t* cond);
pthread_cond_broadcast
:
-
概念:
pthread_cond_broadcast
函数用于向等待在条件变量上的所有线程发送广播信号。条件变量是线程间同步和通信的一种机制,用于等待和通知。 -
参数:
cond
:指向pthread_cond_t
类型的指针,表示要发送广播信号的条件变量。
-
注意事项:
pthread_cond_broadcast
函数会向等待在条件变量上的所有线程发送广播信号,唤醒它们继续执行。如果没有线程等待在条件变量上,调用该函数不会产生任何效果。- 调用
pthread_cond_broadcast
函数并不会立即切换到被唤醒的线程,而是在互斥锁被释放后,被唤醒的线程才有机会获得互斥锁并继续执行。 - 发送广播信号时,条件变量的互斥锁必须是锁定状态。在发送信号前,应先获得互斥锁,并在发送信号后释放互斥锁。
- 发送广播信号后,等待在条件变量上的线程应重新检查条件,以确保条件已满足。因为在多线程环境中,发送信号后,可能会有其他线程修改条件。
pthread_cond_broadcast
函数可以同时唤醒多个线程,而pthread_cond_signal
函数只会唤醒一个线程。
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);
pthread_cond_wait
:
-
概念:
pthread_cond_wait
函数用于在线程等待条件变量上等待,直到收到信号或广播。条件变量是线程间同步和通信的一种机制,用于等待和通知。 -
参数:
cond
:指向pthread_cond_t
类型的指针,表示要等待的条件变量。mutex
:指向pthread_mutex_t
类型的指针,表示与条件变量关联的互斥锁。帮线程释放锁
-
注意事项:
- 在调用
pthread_cond_wait
函数之前,线程必须先获得与条件变量关联的互斥锁。函数在等待期间会自动释放互斥锁,然后在收到信号或广播后重新申请互斥锁,然后继续执行pthread_cond_wait
之后的代码。 - 在等待期间,
pthread_cond_wait
函数会将线程阻塞,直到收到信号或广播。收到信号或广播后,线程会被唤醒并继续执行。 - 等待时,
pthread_cond_wait
函数会原子地解锁互斥锁,并使线程进入等待状态,直到收到信号或广播后重新获取互斥锁。这个原子性是确保线程在等待期间不会丢失信号的关键。 - 如果线程在调用
pthread_cond_wait
函数时条件已经满足,则它不会等待,而是立即继续执行。 pthread_cond_wait
函数返回时,线程将重新申请互斥锁将,因此线程可以安全地访问条件。
- 在调用
我们来使用一下条件变量
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include <cstring>
#include <cstdio>
using namespace std;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
const int num = 5;
void* thread(void* args)
{
const char* name = (const char*)args;
while(true)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex);//调用之后会自动释放锁
cout << name << " 运行" << endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
pthread_t tid[num];
for(int i = 0; i < num; ++i)
{
char* name = new char[32];
snprintf(name, sizeof(name), "线程%d", i + 1);
pthread_create(tid + i, nullptr, thread, (void*)name);
}
sleep(3);
while(true)
{
cout << "主线程唤醒了一个线程" << endl;
pthread_cond_signal(&cond);//唤醒一个线程
sleep(1);
}
for(int i = 0; i < num; ++i)
{
pthread_join(tid[i], nullptr);
}
return 0;
}
所有线程被创建之后都会去获取锁,然后都去条件变量中等待,然后释放锁,再由下一个线程获取锁,所有线程都在条件变量中,主线程再依次唤醒各个线程
修改一下这句代码,看看pthread_cond_broadcast
函数的效果