Win32平台的异步和系统调用
这个笔记我想要探讨一下win32平台下的异步和平台调用方法。
事件对象
CreateEventA是一个windows win32 api提供的事件对象,在我原先的系统设计里,我是打算将AsyncPlatform当作一个跨线程的唤醒机制,我想创建一个windows平台层抽象,其中的CreateEventA创建了一个windows内核事件对象,用来让Runtime的等待线程被唤醒。
struct AsyncPlatform {
HANDLE wake_event;
};
AsyncPlatform* async_platform_create(void) {
AsyncPlatform* platform = calloc(1, sizeof(AsyncPlatform));
if (!platform) return NULL;
platform->wake_event = CreateEventA(NULL, FALSE, FALSE, NULL); //
if (!platform->wake_event) {
free(platform);
return NULL;
}
return platform;
}
CreateEventA的函数原型大致是:
CreateEventA(
_In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
_In_ BOOL bManualReset,
_In_ BOOL bInitialState,
_In_opt_ LPCSTR lpName
);
第一位是安全属性,用于决定这个event对象的安全属性和创建之后句柄是否可以被子进程继承,我们传入NULL就表示默认使用安全属性。第二位是决定手动重置还是自动重置,自动表示某个线程成功等待到了这个event之后windows就会自动把event恢复从signaled -> nosignaled,这个很适合只有一个消费线程的场景。第三个参数决定创建出来是什么状态,选用FALSE就表示创建出来就是nosignaled,这个最常用。最后的就是个event起名字,若是使用NULL就是表示这是一个匿名event,一般匿名事件对象只有在当前进程使用。
void async_platform_destroy(AsyncPlatform* platform) {
if (!platform) return;
if (platform->wake_event) {
CloseHandle(platform->wake_event);
platform->wake_event = NULL;
}
free(platform);
}
这是很典型的资源生命周期成对设计,先释放platform内部持有的资源,然后再释放platform自身。
我们需要把WaitForSingleObject封装一下,async_platform_wait(platform, 1000);表示最多等待1000ms,如果1000ms内的Event被唤醒,就马上返回,如果1000ms内什么都没有发生,就超时返回。
int async_platform_wait(AsyncPlatform* platform, int timeout_ms) {
if (!platform) return -1;
DWORD timeout;
if (timeout_ms < 0) {
timeout = INFINITE;
} else {
timeout = (DWORD)timeout_ms;
}
DWORD result = WaitForSingleObject(platform->wake_event, timeout);
if (result == WAIT_OBJECT_0) {
return 1;
} else if (result == WAIT_TIMEOUT) {
return 0;
} else {
return -1;
}
}
void async_platform_wakeup(AsyncPlatform* platform) {
if (!platform) return;
SetEvent(platform->wake_event);
}
并且timeout_ms < 0的话,就设置timme为infinite,一直等待,不设置超时时间。所以具体来说就是让当前线程等待wake_event,最多等待timeout毫秒。SetEvent win32api会把一个event对象设置为signaled状态,这两个就是配对关系,上面的负责等待event,上面的负责通知event有信号了,可以唤醒了。
线程 A 线程 B
async_platform_wait()
│
▼
WaitForSingleObject()
│
│ 阻塞
│
│ async_platform_wakeup()
│ │
│ ▼
│ SetEvent(event)
│ │
◄───────────────────────────┘
│
▼
被唤醒
│
▼
return 1
那么为了主线程可以不需要被阻塞,还可以继续运行其他同步代码,就需要制作worker线程,因为现在callback是同步执行的,因此callback如果是耗时任务,那么主线程就会被阻塞。worker的工作职责很清晰,就是wait之后取op,设置state为running,并执行callback,完成了再设置completed。
那么首先要思考第一个问题,加入了worker之后main thread和worker thread再pending queue上会有两个线程一起访问,这时候不能继续裸操作这些变量,需要思考线程同步了。在windows上,可以比较简单地使用CRITICAL_SECTION。
static unsigned __stdcall platform_thread_entry(void* arg) {
AsyncPlatformWorker* worker = (AsyncPlatformWorker*)arg;
if (!worker) return 0;
worker->entry(worker->context);
free(worker);
return 0;
}
int async_platform_start_worker(AsyncPlatform* platform, void* (*entry)(void*), void* context) {
if (!platform || !entry) return -1;
if (platform->worker) return -1;
AsyncPlatformWorker* worker = malloc(sizeof(AsyncPlatformWorker));
if (!worker) return -1;
worker->entry = entry;
worker->context = context;
uintptr_t handler = _beginthreadex(NULL, 0, platform_thread_entry, worker, 0, NULL);
if (handler == 0) {
free(worker);
return -1;
}
platform->worker = (HANDLE)handler;
return 0;
}
通过worker来接管异步任务,和使用lock来同步线程之后,我们就不需要run和poll两个方法了,新的架构就可以变成:
submit()
↓
queue
↓
wakeup
↓
Worker
↓
callback
如果想要扩展为多个works支持的话就可以通过在runtime添加count和limit来实现,也是十分简单。我设想,如果为了实用性考虑,一般是会忽略掉runtime的创建的,因此随考虑设置全局count数量和全局的runtime,这样在第一次创建operation的适合顺便创建就可以将runtime的职责隐藏起来,可能更加符合开发者的习惯。并且我们可以使用atexit方法,给程序注册一个正常退出要执行的方法,这样退出就可以正常销毁资源了。
static void async_runtime_cleanup(void)
{
if (async_runtime_global)
{
async_runtime_destroy(async_runtime_global);
async_runtime_global = NULL;
}
}
atexit(async_runtime_cleanup);
之后,我们再去头文件编写一些宏,就可以方便地使用了:
#define ASYNC_WORKER_LIMIT(worker_limit) \
limit_async_worker_count(worker_limit)
#define ASYNC_CREATE(callback, context) \
async_operation_create(callback, context)
#define ASYNC_SUBMIT(operation) \
async_operation_submit(operation)
#define ASYNC_CANCEL(operation) \
async_operation_cancel(operation)
#define ASYNC_AWAIT(operation) \
async_operation_await(operation)
#define ASYNC_RESULT(operation) \
async_operation_result(operation)
#include <stdio.h>
#include <windows.h>
#include "runtime.h"
static void task1(AsyncOperation* operation, void* context)
{
(void)operation;
(void)context;
printf("task start\n");
Sleep(3000);
printf("task done\n");
}
static void task2(AsyncOperation* operation, void* context)
{
(void)operation;
(void)context;
printf("task start\n");
Sleep(3000);
printf("task done\n");
}
int main(void)
{
AsyncOperation* operation1 = ASYNC_CREATE(task1, NULL);
AsyncOperation* operation2 = ASYNC_CREATE(task2, NULL);
// ASYNC_SUBMIT(operation);
// AsyncResult result = ASYNC_AWAIT(operation);
// printf("state = %d\n", result.state);
// return 0;
printf("main continues\n");
Sleep(4000);
printf("state = %d\n", async_operation_state(operation1));
printf("state = %d\n", async_operation_state(operation2));
return 0;
}
我这里不想让runtime来决定主线程的延续,所以开发里还是要开发者自行去判断程序退出的时机。
父子异步
父子异步的价值在于让运行时替你管理关系,如果一个逻辑任务是一颗任务树,比如加载一个页面,其中可能会面临这load_page,这样的话会可能包含了好几个fetch,请求不同资源,这样我们可以这样表达:
load_page(page_id) ← root
├── fetch_header(page_id) ← child A
├── fetch_body(page_id) ← child B
├── fetch_comments(page_id) ← child C
└── fetch_sidebar(page_id) ← child D
用户点了取消或者切换到别的页面,那么有父子的好处就出来了,一次调用,整棵树直接自动取消。因为子任务可能是运行时动态派生的,事先不知道有几个,有多深,用列表手动管理会漏,会乱,父子链让派生这个动作本身自带归属。
因此我们在AsyncOperation的基础上新增几个字段:
AsyncOperation* parent;
AsyncOperation* first_child;
AsyncOperation* sibling_next;
来表达父亲、第一个孩子、下一个兄弟。
我们需要实现一个把operation从它父节点的first_child兄弟链里摘掉,并且清空它的parent/sibling_next。必须在子 operation 即将"不再被父的取消链需要"时调用。核心原则:
tip只要一个子 operation 已经从父的取消链中"用完",就要摘链。否则父的
first_child链里会留着已释放/已结束的节点,造成悬挂指针或误取消。
在runtime_worker里,回调执行完,state定稿之后,需要执行一次:
operation->state = ASYNC_OPERATION_RUNNING;
if (operation->callback)
operation->callback(operation, operation->context);
if (operation->state == ASYNC_OPERATION_RUNNING) {
operation->state = ASYNC_OPERATION_COMPLETED;
operation->error = ASYNC_ERROR_NONE;
}
async_operation_unlink_from_parent(operation); // <<< 这里
这个子已经跑完了,父以后cancel时不需要管他,如果不去掉,父亲cancel的时候还会遍历到,这样链会越来越长,且这个op之后被destory释放,父链里就留下悬挂指针,下次cancel直接崩溃。
实现大致如下:
void async_operation_unlink_from_parent(AsyncOperation* operation) {
if (!operation|| !operation->parent) return;
AsyncOperation* parent = operation->parent;
AsyncRuntime* runtime = operation->runtime;
if (!runtime) {
operation->parent = NULL;
return;
}
async_platform_lock(runtime->platform);
AsyncOperation** current = &parent->first_child;
while(*current) {
if (*current == operation) {
*current = operation->sibling_next;
break;
}
current = &(*current)->sibling_next;
}
operation->parent = NULL;
operation->sibling_next = NULL;
async_platform_unlock(runtime->platform);
}
这里我还想说一下EnterCriticalSection 和 LeaveCriticalSection的原理是什么,我们目前为止到现在锁的实现都是依靠这些,这个是windows上最常用的用户态互斥原语。回想计算机系统的知识,同一进程内互斥,不能跨进程。同一线程可重入(递归锁):同一线程多次enter不会死锁,但必须对应次数的leave。这个底层是 RTL_CRITICAL_SECTION 结构体,不是内核对象。
typedef struct _RTL_CRITICAL_SECTION {
PRTL_CRITICAL_SECTION_DEBUG DebugInfo; // 调试用
LONG LockCount; // 锁计数(负值表示被占用,绝对值-1 = 等待线程数)
LONG RecursionCount; // 重入次数
HANDLE OwningThread; // 当前持有锁的线程 ID
HANDLE LockSemaphore; // 一个事件对象,用于阻塞等待(懒创建)
ULONG_PTR SpinCount; // 自旋次数
} RTL_CRITICAL_SECTION;
LockCount = -1就表示无人持有,-2表示有人持有且有1个线程在等。大多数情况下EnterCriticalSection 走纯用户态路径,他会先尝试用 InterlockedIncrement/CompareExchange 把 LockCount 从 -1 改成 0,成功了的话就设置 OwningThread = 当前线程,RecursionCount = 1,然后直接返回,完全不进入内核。
这个没有系统调用,没有上下文切换,没有内核对象,比mutex快得多,这个也是原因,因为mutex每次WaitForSingleObject都要陷入内核。
当锁被其他线程持有的时候,EnterCriticalSection的时候会自选SpinCount次,默认是0.自选失败的话就会懒创建LockSemaphore,把 LockCount 减到更负,记录等待者数量,然后WaitForSingleObject(LockSemaphore, INFINITE) → 线程睡眠,直到唤醒之后重新尝试获取锁。
LeaveCriticalSection 的时候,RecursionCount--,如果还 > 0,直接返回(重入没退完),清空OwningThread,把 LockCount 加 1(释放),如果 LockCount 表明还有等待者(< -1) → SetEvent(LockSemaphore) 唤醒一个等待线程。
重入的实现:
EnterCriticalSection:
1. 检查 OwningThread == 当前线程?
2. 是 → RecursionCount++,直接返回,不阻塞;
EnterCriticalSection(&cs);
EnterCriticalSection(&cs); // 不会死锁
LeaveCriticalSection(&cs); // RecursionCount 2→1
LeaveCriticalSection(&cs); // RecursionCount 1→0,真正释放
ok讲完了,我们回到刚才的话题,既然这样,那我们的cancel方法也要升级为递归类型的,这样才可以匹配我们的任务:
static void async_operation_cancel_locked(AsyncOperation* op)
{
if (!op) return;
/* 已取消的子树无需重复遍历 */
if (op->state == ASYNC_OPERATION_CANCELLED) return;
/* 只有 PENDING 能直接置为 CANCELLED;
* RUNNING 的 op 只能靠 token 让回调自愿退出,这里改不了 */
if (op->state == ASYNC_OPERATION_PENDING) {
op->state = ASYNC_OPERATION_CANCELLED;
op->error = ASYNC_ERROR_CANCELLED;
}
for (AsyncOperation* c = op->first_child; c; c = c->sibling_next)
async_operation_cancel_locked(c);
}
void async_operation_cancel(AsyncOperation* operation)
{
if (!operation) return;
AsyncRuntime* runtime = operation->runtime;
if (!runtime) return;
async_platform_lock(runtime->platform);
async_operation_cancel_locked(operation);
async_platform_unlock(runtime->platform);
/* 唤醒 worker,让它们丢弃 pending 队列里已取消的 op */
async_platform_wakeup(runtime->platform);
}
这样我们便实现了。
cancellation
我想要实现可以强制停止的异步实现,但是我们的windows平台的实现实际上是通过_beginthreadex实现的多线程,用 TerminateThread 强制杀线程会破坏 C 运行时的状态。这是 Windows 上所有强杀线程方案的坑。
所以,_beginthreadex 创建的线程,绝对不能用 TerminateThread 杀。TerminateThread会立即把线程标记为终止并不等待它运行到安全点,也不立即执行__finally块,SEH的__excpt、C++析构,或是栈展开。不释放任何锁、内存、句柄。不调用CRT的per-thread清理,线程的栈不回收,可能造成泄露整块栈内存。
一般,_beginthreadex 为每个线程分配一个 _tiddata 结构(线程本地存储、errno、strtok 状态、随机数种子等)。正常退出时 _endthreadex 会释放它。但TerminatThread不会调用,所以_tiddata 永久泄漏。如果该线程正在用 CRT 函数(malloc、printf、strtok…),这些函数内部可能持有全局锁(如 _malloc_lock、_io_lock),锁永远不释放
CRT 的 malloc / free 内部有全局堆锁(或 per-heap 锁)。如果线程在 malloc 中途被 TerminateThread 杀掉:
线程 A: 进入 malloc → 拿堆锁 → 正在改空闲链表 → 被 TerminateThread 杀掉
↑ 堆锁永远不释放,空闲链表处于半改状态
线程 B: 调 malloc → 拿不到锁 → 永远阻塞
或拿锁后 → 看到半损坏的空闲链表 → 崩溃 / 内存损坏
目前我们的打断机制只有pedding能够取消,running打断不了,所以还需要支持一下running运行过程中的打断,要支持RUNNING取消,我们需要去加token。
继续更新...