12. Windows多线程编程:从CreateThread到临界区与事件对象

说实话,很多从Linux转过来的朋友,第一次看到Windows的线程API都会有点懵。我当年也是这样——明明在pthreads里一个pthread_create就搞定的事,到了Windows这边怎么冒出个CreateThread,还带一堆参数?

别急,这一章我们就来彻底搞懂Windows的多线程编程。我会带着你从最基础的API开始,一步步深入到临界区、事件对象,最后再跟pthreads做个对比。嗯,这些都是我在实际项目中踩过的坑,希望能帮你少走弯路。

12.1 Windows线程API:CreateThread

Windows创建线程的核心函数就是CreateThread。它的原型长这样:

HANDLE CreateThread(
  LPSECURITY_ATTRIBUTES   lpThreadAttributes,
  SIZE_T                  dwStackSize,
  LPTHREAD_START_ROUTINE  lpStartAddress,
  LPVOID                  lpParameter,
  DWORD                   dwCreationFlags,
  LPDWORD                 lpThreadId
);

参数看着多,其实常用的就三个:线程函数地址、传给函数的参数、创建标志。其他两个填NULL和0就行。

核心要点:CreateThread返回的是一个线程句柄(HANDLE),不是线程ID。句柄才是你操作线程的钥匙。

来看个最简单的例子:

#include <windows.h>
#include <stdio.h>

DWORD WINAPI ThreadFunc(LPVOID lpParam) {
    int threadNum = (int)lpParam;
    printf("线程 %d 正在运行\n", threadNum);
    return 0;
}

int main() {
    HANDLE hThread;
    DWORD threadId;

    hThread = CreateThread(
        NULL,           // 默认安全属性
        0,              // 默认栈大小
        ThreadFunc,     // 线程函数
        (LPVOID)1,      // 传给线程的参数
        0,              // 立即运行
        &threadId       // 接收线程ID
    );

    if (hThread == NULL) {
        printf("创建线程失败,错误码: %d\n", GetLastError());
        return 1;
    }

    // 等待线程结束
    WaitForSingleObject(hThread, INFINITE);
    CloseHandle(hThread);

    return 0;
}

这里有个细节我特别想提醒你:线程函数必须是DWORD WINAPI类型的。我在项目中见过有人写成普通函数,编译能过,但运行起来各种诡异崩溃。为什么?因为Windows内部对线程函数的调用约定有严格要求。

注意:CreateThread创建的线程,一定要用CloseHandle关闭句柄。否则会造成句柄泄漏。我曾经在一个服务程序里忘了关句柄,跑了三天后系统资源耗尽...那教训太深刻了。

12.2 临界区对象(Critical Section)

多线程编程绕不开的一个问题就是同步。Windows提供了临界区对象,说白了就是一把「只能在一个进程内使用的锁」。

临界区的使用分四步:

  1. 初始化:InitializeCriticalSection
  2. 进入:EnterCriticalSection
  3. 离开:LeaveCriticalSection
  4. 销毁:DeleteCriticalSection

看个实际例子:

#include <windows.h>
#include <stdio.h>

CRITICAL_SECTION g_cs;
int g_counter = 0;

DWORD WINAPI IncrementThread(LPVOID lpParam) {
    for (int i = 0; i < 100000; i++) {
        EnterCriticalSection(&g_cs);
        g_counter++;
        LeaveCriticalSection(&g_cs);
    }
    return 0;
}

int main() {
    HANDLE threads[2];
    DWORD threadIds[2];

    // 初始化临界区
    InitializeCriticalSection(&g_cs);

    // 创建两个线程
    for (int i = 0; i < 2; i++) {
        threads[i] = CreateThread(NULL, 0, IncrementThread, NULL, 0, &threadIds[i]);
    }

    // 等待所有线程结束
    WaitForMultipleObjects(2, threads, TRUE, INFINITE);

    // 清理
    for (int i = 0; i < 2; i++) {
        CloseHandle(threads[i]);
    }
    DeleteCriticalSection(&g_cs);

    printf("最终计数: %d\n", g_counter);
    return 0;
}

个人经验:临界区的Enter和Leave一定要成对出现。我见过有人写了个if分支,一个分支里Enter了但没Leave,结果整个程序就卡死了。建议用RAII思想封装一下,或者至少保证Leave在函数的出口处。

临界区有个特点:它是用户态对象,不是内核态对象。这意味着它的性能比互斥量(Mutex)要好得多。但代价是——它不能跨进程使用。

12.3 事件对象(Event Object)

事件对象是Windows里非常灵活的同步机制。它有两种状态:有信号(signaled)和无信号(non-signaled)。说白了,事件就是一个「开关」。

事件分为两类:

  • 自动重置事件:当一个等待线程被释放后,事件自动变回无信号状态
  • 手动重置事件:需要手动调用SetEvent或ResetEvent来改变状态

来看一个典型的生产者-消费者场景:

#include <windows.h>
#include <stdio.h>

HANDLE g_hEvent;
int g_dataReady = 0;

DWORD WINAPI Producer(LPVOID lpParam) {
    printf("生产者: 准备数据...\n");
    Sleep(1000);  // 模拟数据准备
    g_dataReady = 1;
    printf("生产者: 数据就绪,发送信号\n");
    SetEvent(g_hEvent);  // 触发事件
    return 0;
}

DWORD WINAPI Consumer(LPVOID lpParam) {
    printf("消费者: 等待数据...\n");
    WaitForSingleObject(g_hEvent, INFINITE);  // 等待事件
    printf("消费者: 收到信号,处理数据\n");
    return 0;
}

int main() {
    HANDLE hProducer, hConsumer;
    DWORD producerId, consumerId;

    // 创建自动重置事件,初始为无信号
    g_hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);

    hConsumer = CreateThread(NULL, 0, Consumer, NULL, 0, &consumerId);
    hProducer = CreateThread(NULL, 0, Producer, NULL, 0, &producerId);

    WaitForSingleObject(hConsumer, INFINITE);
    WaitForSingleObject(hProducer, INFINITE);

    CloseHandle(hConsumer);
    CloseHandle(hProducer);
    CloseHandle(g_hEvent);

    return 0;
}

关键区别:CreateEvent的第二个参数bManualReset决定事件类型。FALSE=自动重置,TRUE=手动重置。选错了类型,程序行为会完全不一样。

我记得有一次调试一个网络服务程序,多个工作线程等待同一个事件。我用了自动重置事件,结果只有一个线程能收到信号,其他线程永远在等。后来改成手动重置事件,配合ResetEvent手动控制,问题就解决了。

12.4 与pthreads的对比

如果你用过pthreads,你会发现Windows的API虽然名字不同,但概念上有很多相似之处。我整理了一张对比表:

功能 Windows API pthreads
创建线程 CreateThread pthread_create
等待线程结束 WaitForSingleObject pthread_join
互斥锁 CRITICAL_SECTION / Mutex pthread_mutex_t
条件变量 Event Object pthread_cond_t
读写锁 SRWLOCK (Vista+) pthread_rwlock_t
线程局部存储 TlsAlloc / TlsGetValue pthread_key_t

这里有几个关键差异点:

  • 线程函数签名不同:Windows用DWORD WINAPI,pthreads用void*。这个别搞混了。
  • 等待机制不同:Windows的WaitForSingleObject可以等待任何内核对象(线程、事件、互斥量等),而pthreads的pthread_join只能等待线程。
  • 临界区 vs 互斥量:Windows的CRITICAL_SECTION是用户态锁,性能好但不能跨进程。pthreads的pthread_mutex_t默认也是进程内,但可以配置为跨进程。

我的建议:如果你在Windows上写纯用户态程序,优先用临界区而不是互斥量。性能差距在低竞争场景下可能不明显,但在高并发场景下,临界区能快一个数量级。

说到事件对象和条件变量的对比,这里有个有意思的点。pthreads的条件变量需要配合互斥锁使用,而Windows的事件对象是独立的。这意味着事件对象用起来更简单,但灵活性不如条件变量。条件变量可以精确控制「等待某个条件成立」,而事件只能表达「有信号/无信号」两种状态。

Windows多线程同步机制对比 临界区 CRITICAL_SECTION 用户态锁 仅进程内使用 高性能 自动递归 适用:高频短临界区 事件对象 Event Object 内核态对象 可跨进程 自动/手动重置 信号通知机制 适用:线程间信号通知 互斥量 Mutex 内核态对象 可跨进程 命名互斥量 等待超时 适用:跨进程同步 选择原则:进程内高频访问用临界区,跨进程或需要信号通知用事件/互斥量

嗯,这张图把Windows三种同步机制的核心特点都列出来了。你想想看,临界区就像一把只能在自己家里用的锁,事件对象像个门铃,互斥量则像一把可以借给邻居用的锁。选哪个,完全看你的使用场景。

避坑指南:我曾经在写一个Windows服务程序时,用临界区保护了一个全局链表。结果服务需要跨进程通信,临界区根本不管用。后来改成命名互斥量才解决问题。所以,先搞清楚你的同步范围是进程内还是跨进程,再选工具。

最后说一句,Windows的线程API虽然跟pthreads长得不一样,但核心思想是相通的。理解了互斥、同步、信号通知这些概念,换平台只是换个API名字而已。好了,这一章的内容就到这里,希望对你有所帮助。


公众号:蓝海资料掘金营,微信deep3321