目录
一、线程互斥
1、回顾相关概念
2、抢票场景分析代码
多个线程同时操作全局变量
产生原因
如何解决
二、互斥量
1、概念
2、初始化互斥量:
方法1:静态分配
方法2:动态分配
3、销毁互斥量:
4、加锁和解锁
示例抢票:静态分配互斥量
示例抢票:动态分配互斥量
5、加锁粒度
6、深入理解互斥锁
7、互斥量实现原理
交换的现象:内存与%a 做交换
交换的本质:共享<->私有:
8、互斥锁操作场景
三、可重入与线程安全
1、概念
2、常见的线程不安全的情况
3、常见的线程安全的情况
4、重入概念
5、常见不可重入的情况:
6、常见可重入的情况:
7、可重入与线程安全
一、线程互斥
1、回顾相关概念
- 临界资源是指那些在多线程或多进程环境下,不允许同时被多个执行流访问的资源。这些资源的特点在于它们的状态可能因并发访问而变得不确定或引发错误,因此要求在某一时刻最多只能有一个线程或进程对其进行访问和修改。
-
临界区则是指在各个线程或进程中,针对临界资源的那一部分代码区域。当一个线程进入临界区时,它正在访问或操作临界资源,此时应确保其他线程不能同时进入这一相同的代码区域,以防止竞态条件的发生。
-
互斥(Mutex,互斥量)是一种同步机制,用来确保在多线程或多进程环境中对临界资源的互斥访问。互斥量如同一把钥匙,任何时刻只有一个线程能够持有这把钥匙(即获得互斥量),从而进入并操作临界资源。一旦线程完成了对临界资源的访问,它必须释放互斥量,使得其他等待的线程有机会获得这把钥匙并进入临界区。
-
原子性是一种更为底层的概念,在并发编程中指的是一个操作要么全部完成,要么完全不执行,中间状态对外部是不可见的。互斥量的获取和释放通常是在硬件层面支持下实现的一种原子操作,以此来保证线程间的同步行为正确无误。
2、抢票场景分析代码
下面这段代码中出现了多线程访问同一个全局变量tickets
并对其进行减一操作的情景,模拟了多线程环境下并发抢购门票的情况。
tickets
初始值为10000,表示共有10000张门票。每个线程都在循环中尝试购买一张门票,即减少全局变量tickets
的值。- 由于没有使用任何同步机制,这种并发访问和修改全局变量的行为很可能导致数据不一致的问题,即所谓的竞态条件。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
int tickets = 10000; // 全局变量,表示剩余的门票数量
// 线程函数,模拟抢票操作
void* getTickets(void* args)
{
(void)args;//抑制编译器对未使用参数的警告
while(true)
{
// 模拟短暂的延迟
usleep(1000);
// 临界区操作,但此处并未使用锁进行保护
if(tickets > 0)
{
// 打印当前线程和剩余票数
printf("%p: %d\n", pthread_self(), tickets);
// 并发环境下,多个线程可能同时判断tickets > 0为真,然后同时减一,导致数据不一致
tickets--;
}
else{
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3;
// 创建三个线程
pthread_create(&t1, nullptr, getTickets, nullptr);
pthread_create(&t2, nullptr, getTickets, nullptr);
pthread_create(&t3, nullptr, getTickets, nullptr);
// 等待所有线程执行完毕
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
// 输出最后剩余的票数(在实际运行中可能不准确,因为存在竞态条件)
std::cout << "最终剩余票数: " << tickets << std::endl;
return 0;
}
0x7f3c2c001700: 10000
0x7f3c2c001940: 9999
0x7f3c2c001b80: 9998
0x7f3c2c001700: 9997
0x7f3c2c001940: 9996
0x7f3c2c001b80: 9995
... (假设顺利递减,无竞态条件发生)
0x7f3c2c001700: 2
0x7f3c2c001940: 1
0x7f3c2c001b80: 0
最终剩余票数: -1 或者其他非零数值,
取决于最后一个线程何时减少票数以及是否有多个线程同时进行了最后一次减法操作。
拓展知识:(void)args;
这一行的作用是用来抑制编译器对未使用参数的警告。
- 在这个上下文中,
args
是线程函数getTickets
的参数,但是在函数体内并没有使用这个参数。通常情况下,编译器可能会发出未使用参数的警告,为了避免这种警告,将参数强制转换为void
类型并丢弃,以明确告诉编译器我们知道这个参数没有被使用,而且我们故意这样做。- 在多线程编程中,有时候线程函数并不需要从主线程那里接收任何参数,但为了兼容线程创建函数(如
pthread_create
)的接口要求,通常还是会保留一个形参。这里的(void)args;
就是处理这种情况的一种方式。在本例中,参数args
实际并未起到传递有用信息的作用,所以被忽略了。
多个线程同时操作全局变量
-
当多个线程同时进入
if(tickets > 0)
判断时,由于没有加锁或其他同步手段,可能存在一种情况,即多个线程同时判定条件为真,然后同时执行tickets--
操作。这样,最终剩余的票数可能会少于实际应有的数目。 -
若要避免这种情况,需要在对
tickets
进行读取和修改的地方添加互斥锁(例如使用pthread_mutex_t
),确保每次只有一个线程能够进入临界区(修改变量的代码段),其他线程必须等待锁释放后才能继续执行。 -
正确的做法是在修改
tickets
前锁定互斥锁,在修改后解锁互斥锁,这样就能保证多线程环境下数据的一致性。 -
上述代码中的
usleep(1000)
是为了模拟网络延迟或其它消耗时间的操作,使得多线程并发问题更容易出现,实际上在高速并发环境下,即便很小的时间窗口也可能导致竞态条件的发生。
产生原因
在多线程并发编程中,CPU执行线程的调度是由操作系统内核进行的。每个线程有自己的上下文,包括程序计数器(指示下一条要执行的指令)、寄存器(存储临时变量和状态信息)以及其他与线程执行有关的信息。当CPU从一个线程切换到另一个线程时,内核会保存当前线程的上下文,然后恢复下一个要执行线程的上下文。
int tickets = 10000; // 全局变量,表示剩余的门票数量
// 线程函数,模拟抢票操作
void* getTickets(void* args)
{
(void)args;//抑制编译器对未使用参数的警告
while(true)
{
// 模拟短暂的延迟
usleep(1000);
// 临界区操作,但此处并未使用锁进行保护
if(tickets > 0)
{
// 打印当前线程和剩余票数
printf("%p: %d\n", pthread_self(), tickets);
// 并发环境下,多个线程可能同时判断tickets > 0为真,然后同时减一,导致数据不一致
tickets--;
}
else{
break;
}
}
return nullptr;
}
在上述代码示例中,tickets
变量存储在内存中,所有线程都能访问。当两个或更多的线程同时到达if(tickets > 0)
这个判断点时,假设它们都观察到tickets
大于0,如下所示:
- CPU将线程A的上下文切换出去,保存其现场。
- CPU切换到线程B,线程B也判断
tickets > 0
为真,执行tickets--
。 - 线程B的上下文被暂时保存,CPU又切换回到线程A。
- 线程A接着执行
tickets--
,尽管在CPU切换到线程B时,线程A尚未完成减1操作。
这样一来,尽管原本只有1张票可供销售,但由于缺乏同步机制,两张票都被认为已经售出,这就是所谓的“竞态条件”(Race Condition)。这是因为CPU在同一时间窗口内,允许多个线程访问并修改共享资源(tickets
),造成了数据的不一致性。
内存访问方面,由于现代计算机体系结构的缓存一致性协议,在多核CPU中,各个核心的缓存可能持有不同线程对tickets
变量的缓存副本。当一个核心上的线程修改了tickets
的值,该变化可能不会立即传播到其他核心的缓存中。这也是导致多线程并发时数据不一致的另一因素,称为“缓存一致性问题”。
如何解决
为解决这个问题,确实需要引入互斥锁(mutex)或其他同步机制。当一个线程进入临界区(即对tickets
进行读写操作的代码段)时,会先获取互斥锁,确保其他线程在锁未释放前无法进入该临界区。这样,每次只有一个线程可以修改tickets
的值,从而确保了数据的一致性和完整性。
在多线程编程中,为了保证共享资源的访问安全,我们需要一种机制来确保在某一时刻,只有一个线程能访问临界区(即包含修改共享资源的代码段)。上述描述的问题是在无锁机制的情况下,如何原子地更新一个共享变量(例如ticket计数器)。
在多核CPU环境中,对共享变量的操作(如 load、update、store)并非原子操作,也就是说这些操作可能会被中断并在不同线程间交错执行,导致竞态条件(race condition)和数据不一致的问题。
要解决这个问题,确实需要引入互斥量(Mutex)这一同步原语。互斥量提供了一种机制,使得满足以下三个关键点:
-
互斥行为:当一个线程获得了互斥量锁并进入临界区时,其他线程将无法同时获取该锁并进入同一临界区。这就确保了在同一时间内,只有一个线程可以执行临界区内的代码。
-
公平性:如果有多个线程都在等待进入临界区,而此时临界区内没有线程执行,那么当互斥量解锁时,调度器会选择其中一个等待的线程赋予锁,使其进入临界区执行。虽然互斥量并不严格保证“先来后到”的顺序,但在实际应用中很多互斥量实现(包括POSIX互斥量)都会尽量实现公平调度。
-
高效利用资源:当一个线程不在临界区执行,也就是说它已经解锁了互斥量,那么其他等待的线程就可以立即获取锁并进入临界区,不会受到阻碍。
因此,在Linux及支持POSIX线程库的操作系统中,通过使用互斥量API(如pthread_mutex_lock
和pthread_mutex_unlock
),我们可以有效地控制线程对共享资源的访问,从而确保数据的一致性和正确性。
二、互斥量
1、概念
在Linux以及其他支持POSIX线程标准的操作系统中,互斥量是一种基本的同步机制,用于保护共享资源免受并发访问时的数据竞争。
- 对于线程局部存储的数据,由于它们位于各自的栈空间内,天然地受到线程隔离的保护,不会引起数据竞争问题。
- 然而,当多个线程需要共享某些数据时,这些共享变量就会成为潜在的临界资源。
- 如果不采取适当的同步措施(如使用互斥量或者其他同步原语),并发访问共享变量可能导致数据不一致性和其他难以预测的行为。因此,在并发编程中,合理使用互斥量或其他同步工具对于保证数据的正确性和一致性至关重要。
2、初始化互斥量:
方法1:静态分配
通过预定义的常量 PTHREAD_MUTEX_INITIALIZER
可以静态初始化互斥量,这意味着互斥量作为全局变量或静态局部变量声明时可以直接初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
这种方式下,编译器会在程序加载时自动初始化互斥量,无需显式调用 pthread_mutex_init
函数。这样的互斥量通常在整个程序生命周期内有效,并且无需销毁。
方法2:动态分配
如果你需要在运行时动态分配和初始化互斥量,例如从堆上分配,你需要调用 pthread_mutex_init
函数:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
mutex
:指向你想要初始化的互斥量对象的指针。attr
:指向一个互斥量属性对象的指针,若使用默认属性(大多数情况下),可以传递NULL
。通过非默认属性,可以配置互斥量的行为,比如设置它是否为递归锁、错误检查策略等。
3、销毁互斥量:
当你不再需要一个动态初始化的互斥量时,应当调用 pthread_mutex_destroy
函数来释放它所占用的资源:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
mutex
:需要销毁的互斥量对象的指针。
注意事项:
- 静态初始化的互斥量 不需要调用
pthread_mutex_destroy
进行销毁,因为它们的生命周期与程序相同,会在程序结束时自动清理。 - 绝对不能销毁一个已经被加锁的互斥量 ,否则会导致未定义行为,可能引发死锁或其他同步问题。
- 确保所有线程都不会尝试去加锁已被销毁的互斥量 。一旦互斥量被销毁,其状态就不再有效,后续任何对该互斥量的操作都是非法的。
正确使用互斥量的生命周期管理有助于避免潜在的并发错误和资源泄露。
4、加锁和解锁
pthread_mutex_lock
和pthread_mutex_unlock
是 POSIX 线程库中用来实现互斥量(Mutex)加锁和解锁的核心函数。- 互斥量通过
lock
和unlock
函数提供了线程间的一种同步机制,确保同一时间只有一个线程能够访问临界区(即受互斥量保护的代码区域),从而防止数据竞争的发生。在多线程环境下,合理地使用互斥量对于保证数据一致性至关重要。
pthread_mutex_lock(pthread_mutex_t mutex)
此函数用于锁定互斥量。当一个线程调用此函数试图锁定互斥量时,可能出现以下两种情况:
-
互斥量未被锁定:如果当前互斥量处于未锁定状态,调用此函数的线程会立刻获得锁,并将互斥量置为锁定状态,然后函数返回0,表示操作成功。
-
互斥量已被锁定:如果互斥量已经被其他线程锁定,或者有其他线程在同一时刻也尝试锁定互斥量,但尚未成功获取锁,那么调用
pthread_mutex_lock
的线程将会进入阻塞状态(即挂起,不再执行后续代码),直到互斥量被解锁。当互斥量解锁后,该线程会被唤醒并继续执行,最终成功锁定互斥量。
pthread_mutex_unlock(pthread_mutex_t mutex)
此函数用于解锁互斥量。只有当前持有互斥量锁的线程才能成功调用此函数。当调用成功后,互斥量变为未锁定状态,如果有其他正在等待此互斥量的线程,其中一个会被唤醒并获得锁,继续执行。
示例抢票:静态分配互斥量
我们在这个程序中利用三个并发线程模拟抢票过程,通过互斥锁确保了在多个线程间对剩余票数进行原子性操作,从而避免了竞态条件和数据竞争问题。程序执行完成后,将显示最终剩余票数,理论上应为0。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
int tickets = 10000; // 全局变量,表示剩余的门票数量
pthread_mutex_t ticket_mutex = PTHREAD_MUTEX_INITIALIZER; // 静态分配互斥量
// 线程函数,模拟抢票操作
void* getTickets(void*)
{
while (true)
{
// 加锁
pthread_mutex_lock(&ticket_mutex);
// 模拟短暂的延迟
usleep(1000);
// 临界区操作,现在受互斥量保护
if (tickets > 0)
{
// 打印当前线程和剩余票数
printf("%p: %d\n", pthread_self(), tickets);
// 安全地减少票数
tickets--;
// 如果剩余票数为0,则退出循环
if (tickets == 0)
{
break;
}
}
else
{
break;
}
// 解锁
pthread_mutex_unlock(&ticket_mutex);
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3;
// 创建三个线程
pthread_create(&t1, nullptr, getTickets, nullptr);
pthread_create(&t2, nullptr, getTickets, nullptr);
pthread_create(&t3, nullptr, getTickets, nullptr);
// 等待所有线程执行完毕
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
// 输出最后剩余的票数(现在应该是准确的)
std::cout << "最终剩余票数: " << tickets << std::endl;
return 0;
}
-
包含头文件:
<iostream>
:用于标准输入输出操作。<pthread.h>
:包含了创建和管理线程所需的函数声明,以及互斥锁等相关同步原语。<unistd.h>
:提供了usleep
函数,用于模拟线程执行过程中短暂的延迟。
-
全局变量和静态初始化互斥量:
int tickets = 10000;
:全局变量tickets
表示剩余的门票数量,初始化为10000张。pthread_mutex_t ticket_mutex = PTHREAD_MUTEX_INITIALIZER;
:静态初始化了一个互斥锁ticket_mutex
。这个互斥锁将在多个线程间共享,用于保护对tickets
变量的访问。
-
线程函数getTickets:
void* getTickets(void*) { while (true) { // 加锁 pthread_mutex_lock(&ticket_mutex); // 模拟短暂的延迟 usleep(1000); // 临界区操作,现在受互斥量保护 if (tickets > 0) { // 打印当前线程和剩余票数 printf("%p: %d\n", pthread_self(), tickets); // 安全地减少票数 tickets--; // 如果剩余票数为0,则退出循环 if (tickets == 0) break; } else break; // 解锁 pthread_mutex_unlock(&ticket_mutex); } return nullptr; }
- 函数类型为
void*
,符合pthread库要求的线程函数原型。 - 参数为空指针
void*
,此处未使用额外参数。 - 使用无限循环(
while(true)
)不断尝试抢票,直至所有票售罄。pthread_mutex_lock(&ticket_mutex);
:加锁操作,阻止其他线程在此期间访问tickets
。usleep(1000);
:模拟短暂的延迟,比如网络请求或系统处理时间,单位为微秒(1毫秒=1000微秒)。- 进入临界区,此时对
tickets
的操作受互斥锁保护。if (tickets > 0)
:检查还有剩余票否。printf("%p: %d\n", pthread_self(), tickets);
:打印当前线程ID(由pthread_self()
获取)和剩余票数。tickets--;
:安全地减少剩余票数。if (tickets == 0)
:如果票已售罄,跳出循环。
pthread_mutex_unlock(&ticket_mutex);
:解锁互斥锁,允许其他线程继续抢票。
- 函数返回
nullptr
,这是pthread库约定的线程函数返回值类型。
- 函数类型为
-
主函数main:
int main() { pthread_t t1, t2, t3; // 创建三个线程 pthread_create(&t1, nullptr, getTickets, nullptr); pthread_create(&t2, nullptr, getTickets, nullptr); pthread_create(&t3, nullptr, getTickets, nullptr); // 等待所有线程执行完毕 pthread_join(t1, nullptr); pthread_join(t2, nullptr); pthread_join(t3, nullptr); // 输出最后剩余的票数(现在应该是准确的) std::cout << "最终剩余票数: " << tickets << std::endl; return 0; }
- 定义三个
pthread_t
类型的变量t1
,t2
,t3
,分别代表三个线程的句柄。 - 使用
pthread_create
创建三个线程,分别指定它们执行getTickets
函数,且不传递任何附加参数。 - 使用
pthread_join
依次等待这三个线程执行完毕。主线程会在此处阻塞,直到每个子线程都结束其工作。 - 最后,输出“最终剩余票数”,理论上此时应该为0,因为当票数减至0时所有线程都会停止抢票。
- 主函数返回0,表示程序正常结束。
- 定义三个
示例抢票:动态分配互斥量
我们这个程序通过两个并发线程模拟了两个用户同时在线抢购有限数量(10000张)票的过程。互斥锁确保了对共享资源(剩余票数)的访问是线程安全的,而断言有助于检查互斥锁操作的正确性。最后,我们计算并输出了整个抢票过程的运行时间,精确到小数点后两位。
#include <iostream>
#include <pthread.h>
#include <string>
#include <unistd.h>
#include <cassert>
#include <iomanip>
using namespace std;
#define THREAD_NUM 2
int tickets = 10000;
class ThreadData
{
public:
ThreadData(const std::string &n, pthread_mutex_t *pm) : tname(n), pmutex(pm)
{}
std::string tname;
pthread_mutex_t *pmutex;
};
void *getTickets(void *args)
{
ThreadData *td = (ThreadData *)args;
while (true)
{
// 抢占逻辑
int n = pthread_mutex_lock(td->pmutex);
assert(n == 0);
if (tickets > 0)
{ // 判断的本质也是计算的一种
usleep(rand() % 1500);
printf("%s: %d\n", td->tname.c_str(), tickets);
tickets--;
n = pthread_mutex_unlock(td->pmutex); // 也可能出现问题
assert(n == 0);
}
else
{
n = pthread_mutex_unlock(td->pmutex);
assert(n == 0);
break;
}
// 抢完票,还需要后续的动作
usleep(rand() % 2000);
}
delete td;
return nullptr;
}
int main()
{
time_t start = time(nullptr);
pthread_mutex_t mtx;
pthread_mutex_init(&mtx, nullptr);
srand((unsigned long)time(nullptr) ^ getpid() ^ 0x147);
pthread_t t[THREAD_NUM];
// 多线程抢票的逻辑
for (int i = 0; i < THREAD_NUM; i++)
{
std::string name = "thread ";
name += std::to_string(i + 1);
ThreadData *td = new ThreadData(name, &mtx);
pthread_create(t + i, nullptr, getTickets, (void *)td);
}
for (int i = 0; i < THREAD_NUM; i++)
{
pthread_join(t[i], nullptr);
}
pthread_mutex_destroy(&mtx);
time_t end = time(nullptr);
double elapsed_seconds = difftime(end, start);
cout << fixed << setprecision(2) << "time:" << elapsed_seconds << "s" << endl;
}
这段C++代码是一个多线程模拟抢票系统的实现,与之前讲解的版本相同。代码的核心结构和功能保持一致,只是将线程数量(THREAD_NUM
)从10减少到了2。以下是代码的详细解释:
-
包含必要的头文件:
<iostream>
:提供输入/输出流操作。<pthread.h>
:包含POSIX线程库的接口。<string>
:提供字符串操作支持。<unistd.h>
:包含Unix标准函数,如usleep
。<cassert>
:包含断言宏assert
,用于在运行时检测程序内部状态。<iomanip>
:包含操纵符,如setprecision
,用于格式化输出。
-
定义类ThreadData:
class ThreadData { public: ThreadData(const std::string &n, pthread_mutex_t *pm) : tname(n), pmutex(pm) {} std::string tname; pthread_mutex_t *pmutex; };
- 类
ThreadData
用于封装每个线程的相关数据,包括线程名称(tname
)和指向互斥锁的指针(pmutex
)。在创建线程时,将每个线程的名称及共享的互斥锁传递给该类实例。
- 类
-
定义线程执行函数getTickets:
void *getTickets(void *args) { ThreadData *td = (ThreadData *)args; while (true) { // 抢占逻辑 int n = pthread_mutex_lock(td->pmutex); assert(n == 0); if (tickets > 0) { // 判断的本质也是计算的一种 usleep(rand() % 1500); printf("%s: %d\n", td->tname.c_str(), tickets); tickets--; n = pthread_mutex_unlock(td->pmutex); // 也可能出现问题 assert(n == 0); } else { n = pthread_mutex_unlock(td->pmutex); assert(n == 0); break; } // 抢完票,还需要后续的动作 usleep(rand() % 2000); } delete td; return nullptr; }
- 函数
getTickets
接受一个指向ThreadData
实例的指针作为参数。 - 使用while循环持续尝试抢票,直到所有票售罄(
tickets == 0
)为止。- 使用
pthread_mutex_lock
对互斥锁进行加锁,确保同一时刻只有一个线程能访问共享资源(tickets
变量)。 - 断言判断互斥锁加锁是否成功。
- 判断剩余票数是否大于0,若大于0则继续执行以下操作:
- 调用
usleep
模拟随机延迟(0至1500微秒),模拟购票过程中的网络延迟或处理时间。 - 使用
printf
打印当前线程名称和剩余票数。 - 将
tickets
减一,表示已售出一张票。 - 解锁互斥锁,并断言判断解锁是否成功。
- 调用
- 若票已售罄,则直接解锁互斥锁并跳出循环。
- 使用
- 在每次购票后,再次调用
usleep
模拟随机延迟(0至2000微秒),模拟购票后的其他动作(如支付等)。 - 删除传入的
ThreadData
实例,避免内存泄漏。 - 返回nullptr,表明线程执行完毕。
- 函数
-
主函数main:
int main() { time_t start = time(nullptr); pthread_mutex_t mtx; pthread_mutex_init(&mtx, nullptr); srand((unsigned long)time(nullptr) ^ getpid() ^ 0x147); pthread_t t[THREAD_NUM]; // 多线程抢票的逻辑 for (int i = 0; i < THREAD_NUM; i++) { std::string name = "thread "; name += std::to_string(i + 1); ThreadData *td = new ThreadData(name, &mtx); pthread_create(t + i, nullptr, getTickets, (void *)td); } for (int i = 0; i < THREAD_NUM; i++) { pthread_join(t[i], nullptr); } pthread_mutex_destroy(&mtx); time_t end = time(nullptr); double elapsed_seconds = difftime(end, start); cout << fixed << setprecision(2) << "time:" << elapsed_seconds << "s" << endl; }
- 记录当前时间
start
。 - 初始化一个互斥锁
mtx
,用于保护tickets
变量。 - 使用
srand
设置随机数种子,结合当前时间、进程ID和固定常数,确保每次运行程序时生成不同的随机数序列。 - 定义一个数组
t
存储THREAD_NUM
个pthread_t
类型的线程句柄。 - 遍历数组,为每个线程执行如下操作:
- 构造线程名称,格式为"thread X",其中X为线程索引+1。
- 创建一个新的
ThreadData
实例,包含当前线程名称和互斥锁指针。 - 使用
pthread_create
创建新线程,指定线程执行函数为getTickets
,并将ThreadData
实例的地址作为参数传递。
- 使用
pthread_join
等待所有线程执行完毕。这会阻塞主线程,直到每个子线程都完成其任务。 - 销毁互斥锁
mtx
,释放相关资源。 - 记录当前时间
end
,计算并输出程序运行时间(end - start
),保留小数点后两位。
- 记录当前时间
5、加锁粒度
加锁粒度越小越好:
-
减少阻塞范围:较小的锁粒度意味着临界区代码更短,线程持有锁的时间更短。当一个线程持有锁时,其他线程必须等待。因此,缩短锁的持有时间可以减少线程间的等待时间,提高并发性能,降低因线程阻塞导致的上下文切换开销。
-
避免死锁可能性:细粒度锁有助于减少锁的嵌套,从而降低死锁的风险。当多个锁需要按照特定顺序获取时,如果锁的粒度较大,可能会导致复杂的锁依赖关系,增加死锁的可能性。较小的锁粒度使得锁的管理更为简单,更容易避免死锁。
-
提高系统可伸缩性:在高并发场景下,细粒度锁允许更多的线程并行执行,因为它们可以在不影响其他线程所需资源的情况下独立工作。大粒度锁可能导致大量线程因争夺同一锁而陷入等待,限制了系统的并发能力。
实现细粒度锁的策略:
-
局部变量加锁:如果可能,尽量将锁的范围限制在局部变量上,而不是整个数据结构。例如,如果全局变量是一个容器(如列表或字典),不要对整个容器加锁,而是针对每次插入、删除或查找操作单独加锁。
-
分段锁:对于大型数据结构,可以使用分段锁(如Java中的
ConcurrentHashMap
)或细粒度锁数组,将数据分成多个部分,每个部分有自己的锁。这样,即使在高并发情况下,不同的线程可以同时操作数据的不同部分,而不必全部等待同一把锁。 -
原子操作:对于简单的数值型全局变量,如果支持的话,可以使用原子操作(如
AtomicInteger
、std::atomic
等)代替锁。原子操作在硬件级别保证了操作的完整性,无需显式加锁,提供了极细粒度的同步。 -
无锁数据结构和算法:在某些场景下,可以使用专门设计的无锁数据结构和算法,它们通过CAS(Compare-and-Swap)等非阻塞同步原语来实现线程安全,进一步降低锁的使用和开销。
综上所述,为了防止多线程访问全局变量时互相影响,应使用加锁机制确保访问的原子性和一致性。同时,遵循“加锁粒度越小越好”的原则,通过减少阻塞范围、避免死锁以及提高系统可伸缩性,来优化多线程程序的并发性能和稳定性
6、深入理解互斥锁
加锁是否意味着串行执行?
是的,在使用互斥锁保护的临界区内,线程执行是串行的。具体来说,当一个线程成功获取到互斥锁并进入临界区后,其他试图获取该锁的线程将被阻塞,直到持有锁的线程执行完毕临界区代码并释放锁。这种机制确保了在同一时刻,只有一个线程能够访问和修改受保护的共享资源(在这里是 tickets
变量)。尽管线程间的调度仍然是不确定的,但在互斥锁的约束下,对临界区的访问是有序的、不可重叠的,从而实现了对共享资源的串行化访问。
加锁后,线程在临界区中是否会切换,会有问题吗?
线程在临界区中仍有可能被操作系统调度器切换出去,这是正常的线程调度行为。然而,即使发生切换,由于该线程持有锁,其他线程无法进入临界区执行相同的代码。这意味着:
-
数据一致性得到保障:即使持有锁的线程在执行临界区代码时被切换出去,由于锁未释放,其他线程无法干扰正在进行的操作。当持有锁的线程恢复执行时,它将继续从上次中断的地方完成对
tickets
的操作,不会出现数据竞争或竞态条件。 -
原子性体现:互斥锁提供的保护确保了临界区内的操作(如判断剩余票数、输出信息、递减票数)作为一个整体,对于其他线程而言是不可分割的,即具有原子性。即使线程在执行这些操作的过程中被切换,其他线程也无法看到中间状态,只能看到操作完全完成后的结果。
关于锁作为共享资源的理解
正确,锁本身确实是一种共享资源,因为所有试图访问受保护资源的线程都需要与之交互。每个线程都必须先尝试获取这把锁,只有成功获取锁的线程才能进入临界区。其他线程在锁被释放之前只能等待。这种共享性是线程间协作的基础,通过统一的锁机制协调对共享资源的访问顺序。
谁来保证锁的安全?
锁的安全由底层操作系统和/或硬件提供支持。具体实现细节取决于使用的编程语言和平台,但通常包括以下方面:
-
原子性操作:对锁状态(锁定/解锁)的更改通常是通过底层硬件提供的原子指令(如 Compare-and-Swap, CAS)来实现的,确保即使在多处理器环境下,对锁状态的修改也不会被中断或产生竞态条件。
-
内核同步原语:在用户态线程库(如 Pthreads)中,对锁的操作通常封装成系统调用,交由操作系统内核来处理。内核负责在更高层次上管理和调度锁的分配,确保其正确性和安全性。
综上所述,加锁的确意味着对临界区代码的串行执行,即使线程在临界区内被切换,互斥锁的存在确保了数据的一致性和操作的原子性。锁作为共享资源,其安全性由操作系统和硬件的底层支持来保证,确保线程在申请和释放锁时的原子性操作,从而有效地协调多线程对共享资源的访问。
7、互斥量实现原理
swap或exchange指令 以一条汇编的方式,将内存和CPU内寄存区数据进行交换
如果我们在汇编的角度,只有一条汇编语句,我们就认为该汇语句的执行是原子的!
在执行流视角,是如何看待CPU上面的奇存器的?CPU内部的寄存器
本质,叫做当前执行流的上下文!!寄存器们,空间是被所有的执行流
共亭的,但是寄存器的内容,是被每一个执行流私有的!上下文!
在CPU架构中,寄存器是位于CPU内部的小型存储单元,用于临时存储运算数据或指令地址等信息。每个独立的执行线程或上下文都有自己的寄存器状态,这些状态是私有的,也就是说,不同的执行流(如多线程环境中的不同线程或进程)在执行时,它们各自的寄存器内容互不影响。
在单核CPU中,同一时刻只有一个执行流(线程)在运行,CPU通过上下文切换在不同的执行流之间切换。在切换时,CPU会保存当前执行流的寄存器状态至内存(通常是内核堆栈或任务控制块Task Control Block,TCB),然后加载下一个即将执行的线程的寄存器状态。这样一来,尽管寄存器空间物理上是共享的,但每个执行流都有自己独立的一套寄存器值,这就是所谓的上下文。
lock:
movb $0,%al
xchgb al,mutex
if(al寄存器的内容>0){
return 0;
}else
挂起等待;
goto lock;
unlock:
movb $l,mutex
唤醒等待Mutex的线程;
return 0;
交换的现象:内存与%a 做交换
-
lock:这部分是获取互斥锁的过程。首先将寄存器al设置为0(movb $0, %al),然后使用xchg指令交换al的内容与mutex变量的值。如果mutex的初始值大于0,说明已经有其他线程持有该锁,那么当前线程就返回0并挂起等待;否则,它会继续执行并获得锁。
-
unlock:这部分是释放互斥锁的过程。同样地,将寄存器al设置为1(movb $1, mutex),然后唤醒等待Mutex的线程,并返回0。
对于swap
或exchange
这样的指令,它们通常用于在内存和寄存器之间交换数据,而且在一些体系结构中,这类指令是可以保证原子性的,即在多线程环境下,不会有其他线程能在指令执行过程中中断并改变被交换的数据。例如,在x86架构中,可以用xchg
指令实现寄存器和内存位置的数据原子交换。
在执行流视角来看,CPU寄存器就是当前执行流状态的重要组成部分,它们反映了当前执行点的关键信息,如算术逻辑运算的中间结果、函数调用的返回地址、栈指针等。每条执行流有自己的寄存器上下文,保证了各自执行的独立性和连续性。
在汇编层面,swap
或exchange
指令常用于实现内存与CPU内部寄存器间的数据原子性交换。一旦这条汇编指令仅由单条语句构成,我们通常认为该指令在执行期间是不可分割的,即原子操作,不会受到其他执行流的干扰。
交换的本质:共享<->私有:
从执行流的角度审视CPU内部的寄存器,它们本质上构成了当前执行流的运行环境或上下文。尽管所有执行流共享CPU内部的寄存器空间资源,但每个独立的执行流(如线程或进程)对其使用的寄存器内容享有专属权,也就是说,寄存器的具体值是各个执行流私有的状态信息。
换言之,在并发或多线程环境中,尽管物理上的寄存器空间是公共资源,但CPU通过巧妙的上下文切换机制,确保每个执行流在运行时都有自己独特且独立的寄存器内容。因此,可以说寄存器的内容反映了执行流在某一特定时间点的执行状态和局部数据,这种状态是与其他执行流隔离的。
8、互斥锁操作场景
在下方的图示中,可以看到一个CPU和内存之间的交互过程。当CPU尝试获取锁时,它需要检查内存中的mutex变量的状态。如果状态为0,则可以成功获取锁;反之,如果状态非零,则表示另一个线程已经持有了锁,此时CPU需要等待。
这个表格描述了一个典型的多线程环境下的互斥锁操作场景。在这个场景中,有两个线程A和B试图同时访问同一段共享资源(例如一段内存区域或一个变量)。为了防止多个线程同时修改这段共享资源导致的数据不一致或其他问题,我们需要一种机制来保证每次只有一个线程能够访问这段资源。
互斥锁就是这样的一个机制。它提供了一种方式来控制对共享资源的访问,使得在同一时刻只有一个线程能够拥有锁并访问资源。当一个线程想要访问共享资源时,它必须先尝试获取锁。如果锁已经被其他线程持有,那么这个线程就会被阻塞并进入等待状态,直到锁被释放为止。
-
线程A尝试获取锁:
- A线程首先尝试获取锁,由于此时锁还没有被任何线程持有,所以A线程能够成功获取锁。
- 在获取锁之后,A线程就可以安全地访问共享资源而不用担心与其他线程发生冲突。
-
线程B尝试获取锁:
- B线程也尝试获取锁,但由于锁已经被A线程持有,所以B线程无法立即获取锁。
- 此时,B线程会被阻塞并进入等待状态,直到A线程完成对共享资源的操作并释放锁。
-
线程A释放锁:
- 当A线程完成对共享资源的操作后,它会释放锁,这使得其他正在等待锁的线程有机会重新尝试获取锁。
-
线程B获取锁:
- 在A线程释放锁之后,B线程可以从等待状态恢复过来,并尝试再次获取锁。
- 这次,由于没有其他线程持有锁,所以B线程能够成功获取锁并开始访问共享资源。
通过这种方式,我们可以在多线程环境中实现对共享资源的安全访问,避免了数据竞争和其他并发问题。每个线程都必须遵循相同的规则来获取和释放锁,以确保所有线程都能正确地协调它们对共享资源的访问。
三、可重入与线程安全
1、概念
线程安全(Thread Safety)是指在多线程环境下,即使多个线程同时访问同一段代码或数据,也能确保程序行为的正确性和一致性,不会出现因竞态条件而导致的数据不一致、死锁或其他不确定行为。
2、常见的线程不安全的情况
线程不安全的情况常常出现在对全局变量、静态变量进行无同步机制保护的修改操作时。例如:
- 没有采取互斥措施直接操作共享变量的函数。
- 函数内部的状态会在调用过程中发生改变,并且这种改变会影响到后续调用的结果。
- 函数返回的是指向静态存储区变量的指针,这意味着多个线程可能同时读写此变量,造成数据竞争。
- 调用了其他非线程安全函数的函数,除非整个调用链都进行了适当的同步控制。
3、常见的线程安全的情况
线程安全的情形通常包括:
- 所有线程仅对全局变量或静态变量拥有读取权限而不进行写入操作,这样的设计使得多个线程可以并发读取而不会相互影响。
- 设计良好的类或接口,其内部方法保证了在多线程环境下的原子性,即从开始到结束的过程不会被其他线程中断,从而避免了执行结果的不确定性。
- 多个线程在执行特定接口或函数时,即使发生上下文切换,也不会因此导致执行结果的歧义或错误,这是因为这类函数内部实现了必要的同步控制,如使用锁或其他并发控制机制。
4、重入概念
重入(Reentrancy)则是特指一个函数能够被多个执行流(如线程)同时调用,而不会引起任何错误或意外行为的能力。
- 一个可重入函数在其执行过程中,不会依赖于任何全局或静态状态,并且不会阻止其他调用者获取相同的资源。
- 即使在前一次调用尚未完成时又有新的调用介入,只要各个调用之间使用的资源是独立的(如函数只使用局部变量,或对共享资源的访问采用的是线程安全方式),该函数就能保证每次调用都能获得一致且正确的结果。
5、常见不可重入的情况:
-
调用了
malloc
和free
函数:由于这两个函数依赖于全局链表来维护和管理堆内存,因此在多线程环境下,若同时有两个线程试图分配或释放内存,可能会引发竞态条件,导致程序行为未定义。 -
调用了标准I/O库函数:许多标准I/O库的实现中,其内部会以非线程安全的方式使用全局数据结构。当多个线程同时访问这些全局数据时,可能导致数据混乱或程序崩溃。
6、常见可重入的情况:
-
函数不依赖全局变量或静态变量:在多线程环境中,如果一个函数不直接读写任何全局或静态存储区的数据,那么该函数就可以被视为可重入的。
-
不调用非可重入函数:函数在其执行过程中,不调用任何具有上述不可重入特征(如使用全局数据结构、调用
malloc
/free
等)的函数,才能确保自身的可重入性。 -
不返回静态或全局数据:函数的返回值及输出参数均不涉及静态或全局存储区的数据,所有结果由函数的调用者提供的输入参数计算得出。
-
通过创建全局数据的本地副本保护全局数据:若函数确实需要访问全局数据,可以通过在函数内部复制全局数据到局部作用域内,避免对原始全局数据的直接操作,从而实现可重入。
总之,一个函数要具备可重入性,关键在于它不能对共享资源进行竞争性访问,必须独立于其运行环境,并且在任何时候都能被多个并发执行的实体安全地调用。
7、可重入与线程安全
可重入性和线程安全是多线程编程中相关的两个重要概念,它们之间的联系与区别体现在以下几个方面:
联系:
-
可重入函数与线程安全的正相关性: 如果一个函数是可重入的,意味着它在执行过程中不会依赖任何外部上下文(如全局变量或静态变量),并且可以安全地在同一进程中被多个线程同时调用而不互相干扰,从而保证了数据一致性,所以可重入函数必定是线程安全的。
-
共同关注点: 无论是可重入函数还是线程安全函数,它们的核心都是确保在并发环境下的正确执行,即避免因资源共享带来的竞争条件和数据破坏问题。
区别:
-
可重入函数的特性更加严格: 可重入函数不仅在多线程环境下安全,而且能够在递归调用或中断/恢复执行时仍保持正确性,无需担心内部状态冲突。相比之下,线程安全函数只要求在多线程环境下不会因为共享资源的竞争而导致错误结果。
-
线程安全函数范围较宽: 线程安全函数可能依赖于同步机制(如互斥锁)来保护共享资源,防止数据竞争,但并非所有这样的函数都具有可重入性。例如,一个函数在获取锁后进入临界区,如果在其执行过程中再次尝试获取同一把锁(即重入),且之前获取的锁尚未释放,就可能导致死锁,这样的函数就不具备可重入性。
简而言之,可重入函数是线程安全函数的一个子集,它不仅在线程间切换时能保证安全性,还能在函数自身递归调用时维持安全无误的状态。而线程安全函数则可能通过锁定或其他同步手段来避免并发问题,但如果不满足可重入的要求,在特定条件下也可能导致问题,如死锁。