一、前言
在线程编程中,资源共享与保护是一个核心议题,尤其当多个线程试图同时访问同一份资源时,如果不采取适当的措施,就会引发一系列的问题,如数据不一致、竞态条件、死锁等。为了确保数据的一致性和线程安全,多种资源保护机制被设计出来,这些机制主要围绕着资源的互斥访问展开,以防止多个线程同时修改同一份数据而导致的错误。
临界区(Critical Section)
临界区是最基本的资源保护方式之一,它允许同一时间内只有一个线程进入临界区并访问受保护的资源。临界区通过操作系统提供的原语实现,如Windows下的EnterCriticalSection
和LeaveCriticalSection
函数。当一个线程进入临界区时,其他试图进入同一临界区的线程将被阻塞,直到当前线程离开临界区。临界区适用于同一进程内的线程,因为它们共享相同的地址空间,可以快速且有效地进行同步。
互斥量(Mutex)
互斥量是一种更通用的同步机制,它不仅限于同一进程内的线程,还可以跨越进程边界。互斥量提供了比临界区更强大的功能,如命名互斥量,这允许不同进程中的线程可以共享同一个互斥量对象。互斥量通过CreateMutex
函数创建,并使用WaitForSingleObject
和ReleaseMutex
函数进行锁定和解锁。互斥量支持优先级继承,这有助于防止优先级反转问题,即高优先级线程等待低优先级线程释放资源的情况。
信号量(Semaphore)
信号量用于控制对有限数量资源的访问,例如控制并发访问数据库连接的数量。信号量维护一个计数器,当计数器大于零时,线程可以获取信号量并减少计数器的值,从而获得访问资源的许可。当线程释放信号量时,计数器增加,允许其他等待的线程获取信号量。信号量分为二进制信号量和计数信号量,前者只能在0和1之间切换,常用于实现互斥访问;后者可以有任意非负值,用于控制资源的数量。
自旋锁(Spin Lock)
自旋锁是一种非阻塞的同步机制,主要用于短时间的锁定,尤其是在高负载、高频率的访问场景中。当一个线程尝试获取一个已被占用的自旋锁时,它不会被阻塞,而是循环检查锁的状态,直到锁被释放。自旋锁避免了线程上下文切换带来的开销,但在锁长时间被占用的情况下,可能会消耗大量的CPU资源。
读写锁(Reader-Writer Lock)
读写锁允许多个读线程同时访问资源,但只允许一个写线程访问资源,这在读操作远多于写操作的场景中非常有效。读写锁优化了读取性能,因为多个读线程可以同时持有读锁,而写操作则需要独占锁才能进行,以防止数据的不一致性。
条件变量(Condition Variable)
条件变量通常与互斥量结合使用,用于实现线程间的高级同步。当线程需要等待某个条件变为真时,它可以释放互斥量并调用条件变量的Wait
函数。当条件满足时,线程可以被唤醒并重新获取互斥量,继续执行。条件变量是实现生产者-消费者模式、读者-写者模式等复杂同步策略的基础。
在多线程编程中,正确选择和使用这些同步机制对于保证程序的正确性和性能至关重要。开发人员必须仔细分析线程间的交互,识别出可能引起竞态条件的资源,并采取适当的保护措施,以确保数据的一致性和线程的安全运行。同时,过度的同步也可能导致性能瓶颈,因此在设计时还需平衡同步的必要性和程序的效率。
二、实操代码
2.1 互斥量案例-消费者与生产者模型
开发环境:在Windows下安装一个VS即可。我当前采用的版本是VS2020。
创建一个基于互斥量(mutex)的火车票售卖模型,可以很好地展示消费者与生产者关系中资源保护的重要性。在这个模型中,“生产者”可以视为负责初始化火车票数量的角色,而“消费者”则是购买火车票的线程。为了确保在多线程环境中票数的正确性和一致性,需要使用互斥量来保护对票数的访问和修改。
下面是一个使用C语言和Windows API实现的火车票售卖模型的示例代码:
#include <windows.h>
#include <stdio.h>
#define TICKET_COUNT 10
// 定义互斥量
CRITICAL_SECTION ticketMutex;
int ticketsAvailable = TICKET_COUNT;
// 消费者线程函数
DWORD WINAPI ConsumerThread(LPVOID lpParameter)
{
int id = (int)lpParameter;
while (ticketsAvailable > 0)
{
// 进入临界区
EnterCriticalSection(&ticketMutex);
if (ticketsAvailable > 0)
{
ticketsAvailable--;
printf("Consumer %d bought a ticket. Tickets left: %d\n", id, ticketsAvailable);
}
// 离开临界区
LeaveCriticalSection(&ticketMutex);
}
return 0;
}
int main()
{
HANDLE consumerThreads[TICKET_COUNT * 2]; // 假设有两倍于票数的消费者
DWORD threadIDs[TICKET_COUNT * 2];
// 初始化临界区
InitializeCriticalSection(&ticketMutex);
// 创建消费者线程
for (int i = 0; i < TICKET_COUNT * 2; i++)
{
consumerThreads[i] = CreateThread(
NULL, // 默认安全属性
0, // 使用默认堆栈大小
ConsumerThread, // 线程函数
(LPVOID)(i + 1), // 传递给线程函数的参数
0, // 创建标志,0表示立即启动
&threadIDs[i]); // 返回线程ID
if (consumerThreads[i] == NULL)
{
printf("Failed to create thread %d.\n", i);
return 1;
}
}
// 等待所有线程结束
for (int i = 0; i < TICKET_COUNT * 2; i++)
{
WaitForSingleObject(consumerThreads[i], INFINITE);
}
// 删除临界区
DeleteCriticalSection(&ticketMutex);
// 关闭所有线程句柄
for (int i = 0; i < TICKET_COUNT * 2; i++)
{
CloseHandle(consumerThreads[i]);
}
return 0;
}
在这个示例中,定义了一个CRITICAL_SECTION
类型的ticketMutex
互斥量来保护对ticketsAvailable
变量的访问。在ConsumerThread
函数中,每个线程在尝试购买一张票之前,都需要先通过EnterCriticalSection
函数进入临界区,以确保在任何时刻只有一个线程可以修改票数。购买完成后,通过LeaveCriticalSection
函数离开临界区,允许其他线程有机会进入临界区并尝试购票。
虽然创建了两倍于票数的消费者线程,但由于互斥量的存在,最多只会有一张票在同一时刻被售出,从而避免了资源竞争和数据不一致的问题。
此代码演示了如何在多线程环境中使用互斥量来保护共享资源,确保数据的一致性和线程安全。在实际应用中,互斥量是处理多线程并发访问问题的重要工具,尤其是在涉及到资源有限且需要严格控制访问顺序的场景下。
2.2 使用临界区保护共享资源
开发环境:在Windows下安装一个VS即可。我当前采用的版本是VS2020。
使用临界区(Critical Section)来保护共享资源,如火车票数量,在多线程环境中确保数据一致性。
下面是一个使用C语言和Windows API实现的火车票售卖模型,其中包含了生产者初始化票数和多个消费者线程购买票的过程。这个模型将展示如何使用临界区来避免竞态条件,确保所有线程安全地访问和修改票数。
#include <windows.h>
#include <stdio.h>
#define TICKET_COUNT 10
// 定义临界区
CRITICAL_SECTION ticketMutex;
int ticketsAvailable = TICKET_COUNT;
// 消费者线程函数
DWORD WINAPI ConsumerThread(LPVOID lpParameter)
{
int id = (int)lpParameter;
while (1)
{
// 进入临界区
EnterCriticalSection(&ticketMutex);
// 检查是否有票
if (ticketsAvailable > 0)
{
ticketsAvailable--;
printf("Consumer %d bought a ticket. Tickets left: %d\n", id, ticketsAvailable);
}
else
{
// 如果没有票了,退出循环
LeaveCriticalSection(&ticketMutex);
break;
}
// 离开临界区
LeaveCriticalSection(&ticketMutex);
}
return 0;
}
int main()
{
HANDLE consumerThreads[TICKET_COUNT * 2]; // 假设有两倍于票数的消费者
DWORD threadIDs[TICKET_COUNT * 2];
// 初始化临界区
InitializeCriticalSection(&ticketMutex);
// 创建消费者线程
for (int i = 0; i < TICKET_COUNT * 2; i++)
{
consumerThreads[i] = CreateThread(
NULL, // 默认安全属性
0, // 使用默认堆栈大小
ConsumerThread, // 线程函数
(LPVOID)(i + 1), // 传递给线程函数的参数
0, // 创建标志,0表示立即启动
&threadIDs[i]); // 返回线程ID
if (consumerThreads[i] == NULL)
{
printf("Failed to create thread %d.\n", i);
return 1;
}
}
// 等待所有线程结束
for (int i = 0; i < TICKET_COUNT * 2; i++)
{
WaitForSingleObject(consumerThreads[i], INFINITE);
}
// 删除临界区
DeleteCriticalSection(&ticketMutex);
// 关闭所有线程句柄
for (int i = 0; i < TICKET_COUNT * 2; i++)
{
CloseHandle(consumerThreads[i]);
}
return 0;
}
在这个代码示例中,使用InitializeCriticalSection
函数初始化临界区ticketMutex
,并在每个线程的ConsumerThread
函数中使用EnterCriticalSection
和LeaveCriticalSection
函数来保护对ticketsAvailable
变量的访问。这意味着在任何时候,只有一个线程能够修改ticketsAvailable
的值,从而避免了多线程并发访问时可能出现的数据不一致问题。
每个线程在进入临界区检查是否有剩余票之前,都要调用EnterCriticalSection
,而在完成票的购买之后,调用LeaveCriticalSection
来释放临界区,允许其他线程有机会进入并购买票。当票卖完后,线程会退出循环并结束。
通过这种方式,临界区确保了即使在高并发的环境中,火车票的销售过程也能有序进行,每张票只被出售一次,且所有消费者线程都能正确地跟踪剩余票数。