1 线程同步是一个什么样的问题???
如果你编写的是多线程程序,那么多个线程是并发执行,可以认为他们是同时在执行代码。但是线程和线程之间并非是完全的没有关系。很多时候会有以下两种关系:
第一种情况,线程A的继续执行,要以线程B完成了某一个操作之后为前提。 这种需求称之为同步
第二种情况,多个线程在争抢一个资源,比如:全局变量,可以是文件,可以是一个数据结构,可以是一个对象。 这种需求称之为同步互斥。
2 以下的这些机制怎么解决的这个问题??各自又什么区别,分别用于什么样的场景呢???
a. 以下这些机制都能够比较方便的解决互斥问题。
原子操作:原子操作适合去解决共享资源是全局变量的互斥问题。
作用就是对于一个变量的基本算术运算保证是原子性的。
#include <stdio.h>
#include <windows.h>
// 原子操作:
// 特点: 将可能存在的多条汇编指令转换成一条不可拆分的指令
// 缺点: 只能对整数类型进行基本的算数运算原子化,不能保护复杂的代码块
// 函数: InterLockedXXX 系列函数
// 全局变量 g_n,作为一个资源,它被多个线程访问,此时即有可能出现问题
LONG g_n;
DWORD WINAPI ThreadPro1(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
// 使用原子操作替代自增操作后,三条汇编指令被替换为了下面的
// lock inc dword ptr [g_n (0ADA138h)]
// lock 被用于锁死地址总线,此时 CPU 不允许其它核心访问目标内存
InterlockedIncrement(&g_n);
}
return 0;
}
DWORD WINAPI ThreadPro2(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
InterlockedIncrement(&g_n);
}
return 0;
}
int main()
{
HANDLE hThread1 = 0, hThread2;
hThread1 = CreateThread(NULL, NULL, ThreadPro1, NULL, NULL, NULL);
hThread2 = CreateThread(NULL, NULL, ThreadPro2, NULL, NULL, NULL);
WaitForSingleObject(hThread1, -1);
WaitForSingleObject(hThread2, -1);
printf("%d", g_n);
return 0;
}
临界区解决互斥问题:
被保护的代码(代码访问了共享资源)放置在
EnterCriticalSection
LeaveCriticalSection之间即可
临界区具有线程所有权这个概念,必须进入临界区的线程,调用离开临界区,临界区才会被打开。假如加锁的线程崩溃了,其他线程就锁死了。
#include <stdio.h>
#include <windows.h>
// 临界区(关键段):
// 特点: 临界区是一个结构体,通过设置结构体的字段,可以保护连续的一片代码,有【线程拥有
// 者】的概念,线程的拥有者可以重复的进入到临界区中。
// 缺点: 如果拥有结构体的线程崩溃了,其它线程会一直等待,从而产生死锁
// 函数: 初始化临界区 InitializeCriticalSection
// 开始保护 EnterCriticalSection
// 结束保护 LeaveCriticalSection
// 创建一个临界区全局变量,用于进行原子操作
CRITICAL_SECTION CriticalSection{};
// 全局变量 g_n,作为一个资源,它被多个线程访问,此时即有可能出现问题
LONG g_n;
DWORD WINAPI ThreadPro1(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
// 结构体中的 RecursionCount 表示当前需要解锁的次数,进入了多少次
// 临界区,相应的就需要离开多少次。OwningThread 字段表示当前占有
// 临界区的线程
EnterCriticalSection(&CriticalSection);
g_n++;
LeaveCriticalSection(&CriticalSection);
}
return 0;
}
DWORD WINAPI ThreadPro2(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
EnterCriticalSection(&CriticalSection);
g_n++;
LeaveCriticalSection(&CriticalSection);
}
return 0;
}
int main()
{
// 初始化临界区变量
InitializeCriticalSection(&CriticalSection);
HANDLE hThread1 = 0, hThread2;
hThread1 = CreateThread(NULL, NULL, ThreadPro1, NULL, NULL, NULL);
hThread2 = CreateThread(NULL, NULL, ThreadPro2, NULL, NULL, NULL);
WaitForSingleObject(hThread1, -1);
WaitForSingleObject(hThread2, -1);
printf("%d", g_n);
return 0;
}
介绍另外3种机制前,先说两个重要概念
1:
激发态(有信号) 非激发态(没有信号)
WaitForSignaleObject(内核对象,时间);函数的作用,当内核对象处于非激发态的时候,就阻塞住,内核对象处于激发态了,就立即返回。
2:WaitForSignaleObject的副作用,WaitForSignaleObject对于被等待的内核对象有副作用。
#include <stdio.h>
#include <windows.h>
// 可等待对象: 拥有信号状态的内核对象,信号状态通常分为
// - 有信号、激发态、可等待状态
// - 无信号、非激发态、不可等待状态
// 等待函数:
// - 等待多个: WaitForMultipleObjects
// - 等待一个: WaitForSingleObjects
// - 等待函数的副作用: 【可能会将有信号状态变为无信号状态】
DWORD CALLBACK WorkerThtead(LPVOID Paramater)
{
for (int i = 0; i < 100; ++i)
printf("%d: %d\n", (DWORD)Paramater, i);
return 0;
}
int main()
{
// 创建一个数组,用于保存所有的需要等待的内核对象句柄
HANDLE ThreadHandles[10]{};
// 使用循环创建 10 个线程,每个线程都需要等待
for (int i = 0; i < 10; ++i)
ThreadHandles[i] = CreateThread(NULL, 0, WorkerThtead, (LPVOID)i, 0, NULL);
// 等待多个内核对象
// 1. 需要等待多少个内核对象
// 2. 保存内核对象的数组
// 3. 是否需要等待数组内的所有句柄都有信号
// 4. 等待时长
WaitForMultipleObjects(10, ThreadHandles, FALSE, INFINITE);
return 0;
}
互斥体解决互斥问题:
被保护的代码(代码访问了共享资源)放置在
WaitForSignalObject
ReleaseMutex
互斥体也具有线程所有权的概念,得到互斥体的线程,需要自己去释放互斥体。谁加锁,谁开锁。如果得到互斥体的线程崩溃了,互斥体会立即变为激发态。所有等待互斥体的线程中会立即有线程得到互斥体。不会造成死锁的问题。
事件解决互斥问题:需要是自动模式的事件
被保护的代码(代码访问了共享资源)放置在
WaitForSignalObject
SetEvent
事件,没有线程所有权的概念,任何线程都可以释放事件。
临界区的缺点:临界区是在一个进程内有效的,无法在多进程的环境下进行同步。
其次,更为要命的情况,一个线程刚进去临界区,因为奇奇怪怪的原因崩溃了,导致临界区无法被释放,弄得其他线程也没办法再进来临界区,全部卡死造成死锁。
#include <stdio.h>
#include <windows.h>
// 互斥体:
// 特点: 是内核对象,拥有临界区的所有优点,不会死锁,且能够进行进程间同步。
// 缺点: 慢
// 函数: 创建互斥体 CreateMutex
// 开始保护 WaitForXXXObject
// 结束保护 ReleaseMutext
// 作用: 虽然大多数的具名内核对象都可以防多开,但是互斥体用的最多
// 创建一个互斥体内核对象
// 1. 安全属性
// 2. 默认不设置任何的拥有者,第一个等待成功的就是拥有者
// 3. 互斥体的名称,进程间同步会用到
HANDLE Mutex = CreateMutex(NULL, FALSE, NULL);
// 全局变量 g_n,作为一个资源,它被多个线程访问,此时即有可能出现问题
LONG g_n;
DWORD WINAPI ThreadPro1(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
WaitForSingleObject(Mutex, INFINITE);
g_n++;
ReleaseMutex(Mutex);
}
return 0;
}
DWORD WINAPI ThreadPro2(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
WaitForSingleObject(Mutex, INFINITE);
g_n++;
ReleaseMutex(Mutex);
}
return 0;
}
int main()
{
HANDLE hThread1 = 0, hThread2;
hThread1 = CreateThread(NULL, NULL, ThreadPro1, NULL, NULL, NULL);
hThread2 = CreateThread(NULL, NULL, ThreadPro2, NULL, NULL, NULL);
WaitForSingleObject(hThread1, -1);
WaitForSingleObject(hThread2, -1);
printf("%d", g_n);
return 0;
}
事件,没有线程所有权的概念,任何线程都可以释放事件。
信号量:
有信号数这么一个概念,只要信号数不为0,那么就处于激发态。WaitForSignaleObject函数对它的副作用是将信号数减1。
最大信号数为1 的信号量,可以认为是一个事件,可以解决互斥问题。
被保护的代码(代码访问了共享资源)放置在
WaitForSignalObject
ReleaseSemaphore
b. 对于共享资源有序的访问,也可以更关注于要有序。
事件和信号量更适合解决有序的问题。因为他们不要求谁上锁,谁开锁。
用代码实现一个读文件线程,一个写文件线程,请实现先写后读,两个线程都结束之后,主线程结束。
这种没有过多线程同时访问的有顺序的问题,比较适合用事件来解决。
#include <stdio.h>
#include <windows.h>
// 事件:
// 特点: 是内核对象,可以设置为手动的或者自动。
// 缺点: 慢
// 函数: 创建事件 CreateEvent
// 开始保护 WaitForXXXObject
// 结束保护 SetEvent
// 关闭信号 ResetEvent
// 作用: 如果设置为了手动,那么等待函数的副作用就没有了,无法进行互斥操作,但是可以用于同步
// 创建一个事件内核对象
// 1. 安全属性
// 2. 是否需要设置为手动状态
// 3. 默认的信号状态
// 3. 事件的名称,进程间同步会用到
HANDLE Event = CreateEvent(NULL, TRUE, TRUE, NULL);
// 全局变量 g_n,作为一个资源,它被多个线程访问,此时即有可能出现问题
LONG g_n;
DWORD WINAPI ThreadPro1(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
WaitForSingleObject(Event, INFINITE);
g_n++;
SetEvent(Event);
}
return 0;
}
DWORD WINAPI ThreadPro2(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
WaitForSingleObject(Event, INFINITE);
g_n++;
SetEvent(Event);
}
return 0;
}
int main()
{
HANDLE hThread1 = 0, hThread2;
hThread1 = CreateThread(NULL, NULL, ThreadPro1, NULL, NULL, NULL);
hThread2 = CreateThread(NULL, NULL, ThreadPro2, NULL, NULL, NULL);
WaitForSingleObject(hThread1, -1);
WaitForSingleObject(hThread2, -1);
printf("%d", g_n);
return 0;
}
多个信号数的信号量,比较适合解决多个线程之间有顺序需要协调的问题,最为经典的就是生产者消费者问题。
关键点有两个:1 必须有一个队列,可以有数量限制,也可以没有数量限制。我们考虑的是有数量限制的问题
2 每一个生产者是一个线程,每一个消费者也是一个线程。队列满了,生产者需要等待。队列空了,消费者需要等待。
整个的问题是多线程并发时的协调问题。
#include <stdio.h>
#include <windows.h>
// 信号量:
// 特点: 是内核对象,可以有多把锁。
// 缺点: 慢
// 函数: 创建事件 CreateSemaphore
// 开始保护 WaitForXXXObject
// 结束保护 ReleaseSemaphore
// 创建一个互斥体内核对象
// 1. 安全属性
// 2. 初始的信号个数
// 3. 最大的信号个数
// 3. 事件的名称,进程间同步会用到
HANDLE Semaphore = CreateSemaphore(NULL, 1, 1, NULL);
// 全局变量 g_n,作为一个资源,它被多个线程访问,此时即有可能出现问题
LONG g_n;
DWORD WINAPI ThreadPro1(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
WaitForSingleObject(Semaphore, INFINITE);
g_n++;
ReleaseSemaphore(Semaphore, 1, NULL);
}
return 0;
}
DWORD WINAPI ThreadPro2(LPVOID lpThreadParameter)
{
for (int i = 0; i < 100000; i++)
{
WaitForSingleObject(Semaphore, INFINITE);
g_n++;
ReleaseSemaphore(Semaphore, 1, NULL);
}
return 0;
}
int main()
{
HANDLE hThread1 = 0, hThread2;
hThread1 = CreateThread(NULL, NULL, ThreadPro1, NULL, NULL, NULL);
hThread2 = CreateThread(NULL, NULL, ThreadPro2, NULL, NULL, NULL);
WaitForSingleObject(hThread1, -1);
WaitForSingleObject(hThread2, -1);
printf("%d", g_n);
return 0;
}
总结:
原子操作,只能保证对于基本算数操作是原子性的。
临界区和互斥体从词语的含义上看,他们主要就是为了解决互斥问题。
临界区的优点是快。互斥体的优点是能够跨进程访问,崩溃不死锁。
事件 从词语的含义上看,更适合做通知(产生了一个事件)。比较适合解决有先后顺序的多线程问题。
事件和互斥体的最大区别,就是线程所有权。互斥体谁上锁,谁开锁。事件没有这个要求。
信号量,由于存在信号数的问题,比较适合解决多线程的协调问题。典型问题,就是生产者消费者问题。
原子操作:
函数 | 作用 | 备注 |
---|---|---|
InterlockedIncrement | 自增 | InterlockedIncrement(&g_count) |
InterlockedDecrement | 自减 | InterlockedDecrement(&g_count); |
InterlockedExchangeAdd | 加法/减法 | InterlockedExchangeAdd(&g_count, 256L); |
InterlockedExchange | 赋值 | InterlockedExchange(&g_count, 256L); |
临界区
函数 | 作用 | 备注 |
---|---|---|
InitializeCriticalSection | 初始化 | |
DeleteCriticalSection | 销毁 | |
EnterCriticalSection | 进入临界区 | |
LeaveCriticalSection | 离开临界区 |
互斥体
函数 | 作用 | 备注 |
---|---|---|
CreateMutex | 创建互斥体 | 可以给互斥体起名字 |
OpenMutex | 打开互斥体,得到句柄 | 根据名字才能打开互斥体 |
ReleaseMutex | 释放互斥体 | 会使得互斥体处于激发态 |
CloseHandle | 关闭句柄 | 使用完后关闭 |
WaitForSignalObject | 等待互斥体处于激发态 | 等到激发态后,会使得互斥体再次处于非激发态 |
事件
函数 | 作用 | 备注 |
---|---|---|
CreateEvent | 创建事件 | 可以给事件起名字 可以设置两种模式:手工 自动 |
OpenEvent | 打开事件,得到句柄 | 根据名字才能打开事件 |
SetEvent | 释放事件 | 会使得事件处于激发态 |
ResetEvent | 重置事件 | 会使得事件处于非激发态,对手工模式的事件有效 |
WaitForSignalObject | 等待事件处于激发态 | 等到激发态后,对于自动模式的事件会使其再次处于非激发态 |
信号量
函数 | 作用 | 备注 |
---|---|---|
CreateSemaphore | 创建信号量 | 可以给信号量起名字 可以指定最大信号数和当前信号数 |
OpenSemaphore | 打开信号量 | 根据名字才能打开信号量 |
ReleaseSemaphore | 释放信号量 | 会增加信号量的信号数,但是不会超过最大信号数 |
WaitForSignalObject | 等待信号量处于激发态 | 若处于激发态,则会减少1个信号数,信号数位0,将其置为非激发态 |