Windows_多线程
进程和线程的关系
- 进程是程序的运行实例。包含全局变量。
- 一个进程至少包含一个线程
- 程序运行的最小单位是函数,线程则是函数的运行实例。
- 线程是操作系统的术语,而对于处理器来说线程是任务,每个逻辑处理器(或是处理器核心)都可以对应一个线程,并且可以在一个处理器上同时运行不同进程的线程。
- 线程中包含线程栈。
卖票程序
1 |
|
CreateThread创建线程
官方文档:CreateThread function (processthreadsapi.h) - Win32 apps | Microsoft Learn
通常引入<Windows.h>
即可使用。
- 可选,LP即long pointer,看作pointer就行。Security Attributes意为安全描述符。
- 用于ACLs,Access Control Lists。微软常用的手段,哪些东西可以访问,有什么权限等等。可以填NULL得到默认的安全描述符。
- 线程栈大小,填 0 意为默认值,Windows下是1M,Linux是4M。
- 线程函数的地址,告知线程具体从哪里执行。需要遵循函数签名。ThreadProc callback function (Windows) | Microsoft Learn
DWORD WINAPI ThreadProc(_In_ LPVOID lpParameter);
- 以上是线程函数的签名形式。返回值为0代表线程函数执行成功。
- LPVOID指的是
void *
_In_
是一个 SAL(Source Annotation Language)宏, 用于注释 Windows API 函数参数。它提供有关函数参数的额外信息,以帮助编译器进行静态分析和代码检查。我们可以省略。- WINAPI指
__stdcall
- 因此可以简化为:
DWORD WINAPI ThreadProc(void * lpParameter);
C++
中传入函数地址时,需要在函数名字之前加一个&
取地址。
- 可选,函数的参数
- 线程的控制符。
- 0表示创建后立即执行
CREATE_SUSPENDED
表示创建后挂起,直到调用ResumeThread
- 可选,输出值,给一个指针可以获得线程ID。此线程ID与Handle不同。可填NULL。
- 返回值:如果创建成功,返回此线程的句柄。
创建完线程并执行后,需要关闭Handle,但这不意味着关闭线程,只不过是说此句柄不再绑定这个线程,不对线程进行管理了,那么线程运行结束之后就会自行停止。
1 |
|
可能在输出结果中看到两个线程抢占式的输出,导致内容杂乱。
1 | Station main: 10Station 1: 9 |
以上输出有两个问题:
- 还未换行时,线程被抢占。导致换行延后。多行内容在一行显示
- 子线程把0号票卖掉了,这是错误的。
关于输出问题,可以用C++20<format>
的std::format
或者C++23<print>
的std::print
解决。
1 | std::wcout << std::format(L"Station main: {}\n", tickets--); |
或
1 | std::println("Station main: {}", tickets--); |
Sleep把时间片放大,观察错误
通过sleep把时间片放大,可以让多线程中隐患的几率增大。
Windows下的Sleep定义于
Windows.h
,参数是毫秒。
1 |
|
最后可能输出的情况:main线程把0号票卖了,最后导致票余量为-1。
1 | Station main: 9 |
可能的情况:main线程判断tickets为1后准备卖票,睡了5ms期间被1线程卖掉了最后一张票,之后main线程唤醒后是不知道的,依旧减了票量到-1。
我们发现,如果使用的是
std::println
,0号票卖掉的概率大大下降。大多数情况剩余票数为0(正确行为)。这可能是因为std::println
/控制台输出内部通常有锁,调用很“重”,会让线程轮换得更慢,进一步降低了极端交错(比如刚好重读为 0 再减)的概率。但这样对tickets
的访问并不会变安全。
总结:控制台输出里自带锁,反而会“串行化”一部分时间,不利于制造极端交错。
CreateMutex创建互斥量
CreateMutexW function (synchapi.h) - Win32 apps | Microsoft Learn
通常引入<Windows.h>
即可使用。
- 可选,安全描述符,可填NULL获得默认的。
- 是否起始就占有互斥量:此时的线程是不是立马持有互斥量而其他线程无法访问。
- 可选,名字。
- 返回值是句柄。
在此例我们要给这个互斥量一个全局可访问的句柄。
WaitForSingleObject - 加锁
在临界区之前需要WaitForSingleObject
等待互斥量的信号。相当于加锁
WaitForSingleObject function (synchapi.h) - Win32 apps | Microsoft Learn
通常引入<Windows.h>
即可使用。
- 指明等待的对象的句柄
- 等待的时间
- 可填非0的值
- 如果填0,而没有信号,则会立即返回,一直重复试探、空转。
- 可以填INFINITE,无限期等下去。
ReleaseMutex - 释放互斥量
临界区后需要ReleaseMutex(hMutex)
释放互斥量。而且要注意,在if语句的每个情况下都需要写释放锁语句。
卖票代码
1 |
|
结果:
1 | Station main: 10 |
不会出现卖0号票的情况。
使用标准线程库创建2个子线程卖票
1 |
|
由于我们在子线程结束后还有CloseHandle、输出最后票数的操作,使用需要手动控制th.join()
的位置。
1 | th1.join(); |
或者也可以detach,但是主线程要在末尾主动Sleep
一段时间。
1 | th1.detach(); |
结果:
1 | seller 1: 10 |
命名锁
比如要想做应用程序的单例模式。
可以用锁和内核对象进行控制。
为什么要用名字呢?
因为每个线程创建完之后内核返回的句柄值时随机的,线程只知道自己的,而不知道其他人的句柄。但当程序员主动在创建线程时给了名字后,系统就有所感知,你之前已经创建过这个东西,就会把之前的句柄返回给你,就可以访问相同的对象了。
1 | constinit HANDLE hMutex = nullptr; |
VS下多线程调试
- 下断点。
- 先运行一个程序。
- 在Sleep 10s结束之前。右击项目名,Debug,Start New Instance。
- 会发现,第二个程序会直接到达
closeHandle(hSingletonMx)
。 - 这就是跨应用程序的singleton
CreateEvent创建事件(信号、条件变量) - Windows下的独特锁
事件提供了比Mutex更多的功能。
用CreateEvent
创建。
CreateEventW function (synchapi.h) - Win32 apps | Microsoft Learn
- 可选,安全描述符,填NULL表示获取默认描述符。
- 是否手动重置,意思是如果是手动重置则需要在
WaitForSingleObject
之后ResetEvent(hEvent)
手动上锁,在此期间可能会被其他人抢占该信号。如果是自动重置则是Wait后自动上锁。 - 初始状态,true为有信号(signaled),false为无信号(nonsignaled)。有信号意为没有上锁,无信号时需要自己把锁打开。
- 可选,命名或匿名。
SetEvent:释放信号
对于Event来说,退出临界区的方法为SetEvent(hEvent)
卖票代码修改
使用Event时需要做以下修改:
- main函数中
CreateEvent
- main函数最后
CloseHandle
参数改为hEvent - 线程函数中
- WaitForSingleObject参数改为hEvent
- 退出临界区的开锁对于Event来说方法为
SetEvent(hEvent)
以下,创建2个子线程。
1 | constinit HANDLE hEvent = nullptr; |
不同
- Mutex与拥有者绑定,只能听从拥有者支配。
- Event没有绑定拥有者,任何人只要拿到它的句柄就可以支配。
- Event更像是一个信号的概念,给别人通知。Linux下的对标物:条件变量
WaitForMultipleObjects
使用WaitForMultipleObjects
。常常用于:当作主线程的信号接收器,当两个子线程全部发出结尾的SetEvent信号时,主线程就可以停止阻塞,继续执行,从而取代了死板的Sleep。
WaitForMultipleObjects function (synchapi.h) - Win32 apps | Microsoft Learn
- 事件对象的个数
- 事件对象句柄数组的首元素指针
- 是否等待所有事件对象全部到位再退出
- 0是空转,INFINITE是无限期,非0是最多等多久后退出。
两个子线程的完成事件信号,初始状态必须是无信号,即在创建时,CreateEvent
的第三个参数为false。
两个子线程在结束时,需要SetEvent(hEventExits[0 or 1]);
进行开锁。
1 | constinit int tickets = 100; |
输出:
1 | ... |
Windows临界区
- 不是Windows内核对象,只是C语言下用户态的结构体。
- 用
CRITICAL_SECTION
声明、定义。 - 用
InitializeCriticalSection
初始化 - 用临界区就不用Mutex或Event了
- 保护临界区用
EnterCriticalSection
- 离开临界区用
LeaveCriticalSection
- 进程的最后要销毁,
DeleteCriticalSection
1 | constinit int tickets = 100; |
输出
1 | Station #1: 1000 |
特点总结
- 在用户态下,速度比内核态的Event、Mutex快
- 也能实现同步保护,但是调配度没有那么细致,某一线程一直独占临界区的概率较大。
死锁
- 加一个临界区对象
- 线程1先进入临界区1再进入临界区2,退出时先退出2再退出1
- 线程2先进入临界区2再进入临界区1,退出时先退出1再退出2
1 | CRITICAL_SECTION cs; |
输出
1 | Station #1: 1000 |
这种情况是因为双方各自都需要两种锁,但是各自只持有一种锁,都在等待另一种锁释放。
多线程UI
VS新建项目,Windows Desktop Wizard,下一步,改名为Multi-Threading-WithUI
,Create。弹出Windows Desktop Project,Application Type选择Desktop Application (.exe),Additional options选择Empty project,OK。
手写创建控件。手册查阅:
Progress Bar - Win32 apps | Microsoft Learn
About Progress Bar Controls - Win32 apps | Microsoft Learn
可以通过使用CreateWindowEx
函数并指定PROGRESS_CLASS
窗口类(定义于CommCtrl.h
)来创建进度条。这个窗口类是公共控件DLL被加载后注册的。
即需要在ShowWindow显示主窗口后再CreateWindowEx
创建一个子窗口。参数如下:
- dwExStyle为叠放式
- 类名为PROGRESS_CLASS
- 窗口对象名字
- dwStyle为子窗口WS_CHILDWINDOW
- X、Y,宽度、高度
- 父窗口hWndParent
- hMenu
- hInstance
- lpParam
Windows遵循“一切皆窗口”原则,进度条也是一个窗口,因此需要通过创建子窗口来创建进度条。
在WndProcedure的case WM_CREATE
中创建。
自定义消息,创建进度条完成:
1 |
在case CONTROL_FIN
中,可以发送进度条的消息:
- PBM_SETRANGE message用于设置进度条的最小值和最大值,并重绘进度条以反映新的范围。
- PBM_SETPOS message用于设置进度条的当前位置并重绘进度条以反映新位置。
1 | HWND hProgressBar = nullptr; |
以上UI程序的效果:
一个主窗口,包含一个进度条,50%。
单线程消息循环实现进度条动画
- 想要通过按某个键,触发、通知进度条上涨至100%。
- 并能同时显示多个进度条,每个进度条按不同速度走。
- 同一时刻要让最多两个进度条能动。
PBM_STEPIT message用于将进度条的当前位置向前推进步长增量,并重绘进度条以反映新位置。应用程序通过发送PBM_SETSTEP消息来设置步长增量。
1 | case CONTROL_FIN: // 自定义消息:创建进度条完成 |
以上程序的问题是,一直没有进度条的动画,必须得等for循环完毕,进度条才能动,但是是一下子到头。
因为:
我们现在是PostMessage发送进度条消息,导致其他消息不能同时处理。
因此要用SendMessage,不进消息队列,直接操作控件,控件就可以立即响应了。
但是仍然存在问题:进度条结束前不能移动主窗口(移动主窗口是发送MOVE消息)。
UI消息驱动工作的原理是什么?
wWinMain函数中有一个消息循环。
我们需要在case WM_KEYDOWN
中也加入这样一个消息循环。
但是,GetMessage是以阻塞方式获取消息的,需要切换为PeekMessage。PeekMessageW function (winuser.h) - Win32 apps | Microsoft Learn
1 | case WM_KEYDOWN: |
多线程消息处理
按下S键后,创建一个子线程,在子线程中进行发送消息。
子线程发送消息时,就没必要使用SendMessage了,使用PostMessage就可以,PostMessage是往主线程中的消息队列里投放消息。
但是要注意,由于创建的是jthread线程,最后会自动join,导致主线程会等待其操作完毕,那么主窗口就不能处理其他消息,因此子线程需要detach。
1 | case WM_KEYDOWN: |
通过条件变量控制子线程
我们把原本按下S键的内容直接让其在case CONTROL_FIN
中完成,意味着创建进度条完成后,并且设置好进度条的属性后,立即创建子线程,但是要用条件变量控制:
用Event控制
- 在全局定义一个
h_start_event
- 在
case CONTROL_FIN
中CreateEvent与h_start_event
绑定 - 在jthread t函数体中,WaitForSingleObject等待
h_start_event
- 在
case WM_KEYDOWN
的F键中,SetEvent
1 | HANDLE h_start_event{ nullptr }; |
跨平台条件变量控制
- 在全局定义一个
std::mutex start_mx
和std::condition_variable start_cv
。 - 在jthread t函数体中,定义
unique_lock lck{start_mx}
获取锁,之后start_cv.wait(lck)
- 在
case WM_KEYDOWN
的F键中,start_cv.notify_one()
1 | std::mutex start_mx; |
多个进度条
- 全局定义3个
hProgressBar
。 - 在wWinMain中CreateWindowEx创建这3个进度条,可以赋予不同初始属性。并且要三个全部show、Update
case CONTROL_FIN
中PostMessage配置每个ProgressBar的属性。
1 | HWND hProgressBar{ nullptr }, hProgressBar2{ nullptr }, hProgress3{ nullptr }; |
效果如下:
Windows信号量
CreateSemaphore创建信号量
创建的API为CreateSemaphore
- 可选,安全描述符,如果是NULL则获得默认的安全描述符。如果是NULL则该句柄不能被子进程继承。
- 信号量的初始值。必须大于等于0。
- 信号量的最大值。必须大于0。
- 可选,信号量内核对象的名字。如果是NULL则没有名称。
WaitForSingleObject信号量减
1 | ::WaitForSingleObject(h_semaphore, INFINITE); |
ReleaseSemaphore信号量加
- 信号量对象的句柄
- 要增加的量,必须大于0。但是如果该值大于信号量的最大值,则无效,不做更改,返回FALSE。
- 一个指针,用于接收信号量先前的数值,可以为NULL。
示例:多个进度条同时动画
- 以下程序的效果是,按下F键后,同时可以有两个条进行加载,两个条中如果有一个加载完后,第三个条开始加载。
- 三个线程需要有条件变量来等待F键按下,而条件变量需要锁,而后wait。
- case F需要给他们notify_all
- wait成功后进行获取信号量,信号量初始值、最大值为2。
- 但是要求要有两个同时加载,所以某一个线程不能一直拿着一把锁,因此需要wait成功后unlock。
- 结束for循环后释放信号量,即加1。
在上一节《多个进度条》的基础上,需要做的是:
- 在
case CONTROL_FIN
中创建3个线程,对应3个进度条。 - 在
case CONTROL_FIN
中创建信号量h_semaphore
。 - 每个线程需要wait按下F键的通知,所以需要先获取锁。
- wait成功后需要unlock。
- 获取信号量。
- for循环结束后,释放信号量。
- 3个jthread线程在最后detach。
1 | { |