C语言Windows多线程编程实战:从创建线程到同步与线程池
简介面向Windows环境下C语言开发者这份基于VS2015的C语言多线程示例包聚焦线程创建、同步与通信三大核心环节适合刚接触Windows API多线程编程的初学者快速建立落地认知。压缩包仅3个文件2个C源文件与1个头文件整体约1KB结构精简主程序演示CreateThread创建线程与WaitForSingleObject等待逻辑线程实现文件存放入口函数具体代码头文件负责声明与接口组织示例代码附有简明注释便于对照理解每一步操作意图。目前已有2222人学习下载口碑可见。内容不仅覆盖线程创建全流程还给出互斥量保护临界区、TLS线程局部存储等同步通信关键片段帮助理解事件、信号量等衍生机制。通过学习这套demo可掌握Windows下多线程API的基本用法理清线程函数、句柄、同步对象之间的协作关系也可作为课程设计或面试前快速复习的实用参考为后续写出线程安全的高并发程序打下基础。1. 从一次界面卡死说起C 语言多线程在 Windows 下的真实需求一个 C/S 架构的桌面工具主线程里做了一次文件哈希计算文件稍大一点界面就卡成白屏鼠标转圈转到怀疑人生。当时我用的还是单线程数据一多就翻车。后来把耗时操作扔进工作线程界面立刻活了——这就是 C 语言多线程最朴素的实用价值把阻塞主流程的任务拆出去让程序保持响应。这篇笔记围绕 Windows 平台Win32 API 和 CRT 两种路径整理 C 语言创建线程、同步、销毁的完整链路适合刚接触线程编程、被死锁和数据竞争坑过的开发者。资源核心是一份可编译运行的 C 语言多线程 Demo 包覆盖线程创建、互斥、信号量、生产者-消费者模型照着敲一遍能省下大量翻 MSDN 的时间。2. 创建线程的三种姿势CreateThread 与 _beginthreadex 的选型博弈2.1 三种创建接口的区别与适用边界Windows 下创建线程最常见的三条路是CreateThread、_beginthread和_beginthreadex。很多人上来就用CreateThread因为它最直观——直接调 Win32 API不需要引入额外头文件。但这里有个隐藏问题C 运行时库CRT在早期版本里有些全局状态是线程私有的如果直接用CreateThread创建的线程调用了 CRT 函数比如strtok、rand、errno这些函数内部依赖的 TLS线程局部存储可能没初始化轻则数据错乱重则进程崩溃。_beginthreadex是 CRT 提供的封装它内部先帮线程初始化 TLS再调用CreateThread真正创建。从 C 语言的角度看_beginthreadex是更稳妥的选择因为它让 CRT 的线程局部状态可用CreateThread不是不能用而是有使用前提——要么你确定创建的线程不使用任何 CRT 函数要么你用的编译器是较新版本且 CRT 做了适配。我一般会优先用_beginthreadex参数跟CreateThread几乎一一对应多了一个unsigned返回值类型别的差别不大。_beginthread则是更老的一代接口线程结束时会自动调用CloseHandle导致外部无法用WaitForSingleObject等待线程退出适合“创建完就不管”的简单场景。但生产代码里更多时候需要等待线程结束、获取退出码所以_beginthreadex是这三个里的最优选择——前提是你需要 CRT 支持如果不需要 CRT用纯 Win32 的CreateThread也完全可以。2.2 手写一个最小可运行的多线程 Demo这一段是核心操作。以下代码演示了_beginthreadex创建两个工作线程各自打印一段日志。注意变量hThread用来保存线程句柄WaitForMultipleObjects用来等待所有线程退出避免主线程先结束导致工作线程被强制终止。#include stdio.h #include process.h #include windows.h // 线程函数无符号整型指针参数返回 unsigned unsigned __stdcall worker(void* arg) { int id *(int*)arg; for (int i 0; i 3; i) { printf(线程 %d 正在工作: %d\n, id, i); Sleep(200); // 模拟耗时200ms } return 0; } int main() { HANDLE hThreads[2]; int ids[2] {1, 2}; for (int i 0; i 2; i) { // _beginthreadex 的第 4 个参数是传给线程函数的参数指针 hThreads[i] (HANDLE)_beginthreadex(NULL, 0, worker, ids[i], 0, NULL); if (hThreads[i] NULL) { printf(线程创建失败: %d\n, errno); return 1; } } // 等待两个线程都退出 WaitForMultipleObjects(2, hThreads, TRUE, INFINITE); // 关闭句柄释放内核对象 for (int i 0; i 2; i) { CloseHandle(hThreads[i]); } printf(所有线程已结束\n); return 0; }逻辑说明ids[i]是三个线程各自的编号传数组元素地址给线程函数线程函数里通过*(int*)arg解引用拿到编号。这里有个关键点参数指针必须在线程用到它的时候仍然有效——传栈上变量给线程是可行的但前提是主线程在子线程读完这个参数之前不退出该栈帧。上面的例子中ids是main的局部变量作用域覆盖整个main所以安全。参数说明_beginthreadex的第三个参数是线程函数指针必须是unsigned (__stdcall*)(void*)形式第五个参数0表示立即运行最后一个NULL表示不返回线程 ID。如果创建失败返回NULL并设置errno用printf打印错误信息方便排查。释放句柄用CloseHandle线程退出后句柄不关会导致内核对象泄漏——这是多线程程序里最常见的资源泄漏。2.3 线程参数传递与返回值收集的常见取舍多线程编程里给线程传参是容易踩坑的地方。常见做法是动态分配一块结构体把多个参数打包进去线程用完释放。这里要注意如果你分配内存后把指针传给线程在线程函数里 free 这块内存而主线程又可能在某个时刻去读这块内存就会出问题。稳妥的顺序是线程函数内先复制数据再释放主线程不再触碰这块内存。#include stdio.h #include stdlib.h #include process.h #include windows.h typedef struct { int id; const char* name; } ThreadParam; unsigned __stdcall worker(void* arg) { ThreadParam* p (ThreadParam*)arg; // 先复制数据再释放内存 int id p-id; const char* name p-name; printf(%s(%d) 启动\n, name, id); free(p); // 线程自己释放参数内存 return 0; } int main() { HANDLE h; ThreadParam* param (ThreadParam*)malloc(sizeof(ThreadParam)); param-id 42; param-name 工作线程; h (HANDLE)_beginthreadex(NULL, 0, worker, param, 0, NULL); if (h NULL) { free(param); // 创建失败时内存仍然由主线程释放 return 1; } WaitForSingleObject(h, INFINITE); CloseHandle(h); return 0; }逻辑说明param在主线程栈上分配不安全——如果main先结束栈内存被系统回收但线程还在运行会读到已失效的数据。改成堆上分配后线程函数内部先备份数据再free确保内存不泄漏同时避免悬空指针。这段代码最容易翻车的点是_beginthreadex创建失败后没有free——但代码里已经覆盖了这个分支。常见取舍如果你需要线程返回值用WaitForSingleObject等待线程结束后调用GetExitCodeThread。线程函数的返回值是unsigned会作为退出码保存。获取线程值的顺手做法是把结果写入传入的参数指向的变量——比如把结果存入param-result线程退出后由主线程读取比GetExitCodeThread更直观也更适合多线程并发返回的场景GetExitCodeThread只能等线程完全结束才能读。本文档配套的 Demo 包里两个版本都有新手建议先跑通WaitForSingleObject版本。3. 线程同步关键段、互斥量与信号量的 Win32 实现细节3.1 关键段CRITICAL_SECTION为何是默认首选线程安全的核心是“同一时刻只有一个线程访问共享资源”。Windows 下实现互斥有两条路关键段和互斥量。关键段CRITICAL_SECTION只能在同一进程内使用但它的用户态自旋特性让它在短临界区的性能上碾压互斥量。所谓自旋就是线程尝试进入临界区时如果发现锁被占用它会先空转一小段时间而不是立刻睡眠——如果锁很快释放空转比线程切换省得多。互斥量Mutex是内核对象每次加锁解锁都要进入内核态开销大一个量级但它的优势是跨进程可用且支持等待超时和命名互斥。选择原则很直接进程内同步优先关键段跨进程同步用互斥量。个人经历是90% 的业务场景都只需要关键段——不要为了“更安全”去选互斥量性能损失不值。关键段的标准用法是初始化、加锁、解锁、删除四个步骤配合InitializeCriticalSection和DeleteCriticalSection成对出现。#include stdio.h #include process.h #include windows.h CRITICAL_SECTION g_cs; int g_counter 0; // 共享资源 unsigned __stdcall worker(void* arg) { int id *(int*)arg; for (int i 0; i 10000; i) { EnterCriticalSection(g_cs); // 加锁 g_counter; // 临界区操作共享变量 LeaveCriticalSection(g_cs); // 解锁 } printf(线程 %d 完成\n, id); return 0; } int main() { InitializeCriticalSection(g_cs); HANDLE hThreads[2]; int ids[2] {1, 2}; for (int i 0; i 2; i) { hThreads[i] (HANDLE)_beginthreadex(NULL, 0, worker, ids[i], 0, NULL); } WaitForMultipleObjects(2, hThreads, TRUE, INFINITE); printf(最终计数: %d\n, g_counter); DeleteCriticalSection(g_cs); for (int i 0; i 2; i) CloseHandle(hThreads[i]); return 0; }逻辑说明两个线程各执行 10000 次g_counter如果不加锁最终结果大概率小于 20000因为不是原子操作——它对应“读-改-写”三步可能被其他线程中途打断丢失更新。加上关键段后EnterCriticalSection保证同一时刻只有一个线程进入临界区其它线程在此等待。这段代码你跑一次就能直观体会“数据竞争”——注释掉加锁两行试试输出会小于预期这就是多线程最经典的教学实验。参数说明InitializeCriticalSection会自动分配内存不需要手动 freeDeleteCriticalSection在确保没有线程使用它之后调用否则会出未定义行为。关键段还有个优化点TryEnterCriticalSection尝试加锁而非阻塞如果锁被占用直接返回 FALSE适合做“锁不住就跳过”的非阻塞逻辑。3.2 互斥量与信号量的使用场景拆解跨进程同步的场景一个典型例子是防止多个进程同时写同一个文件。命名互斥量可以做到进程间互斥——两个进程都创建同一个名字的Mutex谁先获取到锁谁执行写文件另一个进程在WaitForSingleObject处阻塞。#include windows.h #include stdio.h int main() { // 创建命名互斥量 HANDLE hMutex CreateMutexA(NULL, FALSE, Global\\MyAppFileLock); if (hMutex NULL) { printf(创建互斥量失败\n); return 1; } // 如果互斥量已存在GetLastError 返回 ERROR_ALREADY_EXISTS if (GetLastError() ERROR_ALREADY_EXISTS) { printf(另一个实例已在运行等待锁...\n); } // 请求锁等待最多 5 秒 DWORD waitResult WaitForSingleObject(hMutex, 5000); if (waitResult WAIT_OBJECT_0) { printf(获得锁开始写文件\n); // 模拟写入 Sleep(2000); // 释放锁 ReleaseMutex(hMutex); } else { printf(等待超时或失败代码: %lu\n, waitResult); } CloseHandle(hMutex); return 0; }逻辑说明CreateMutexA第二个参数FALSE表示当前线程不立即拥有锁Global\\MyAppFileLock是互斥量名字Global\前缀表示系统全局域所有会话可见。第一次运行这个程序互斥量被创建GetLastError为NO_ERROR第二次启动时拿到同一个互斥量GetLastError变为ERROR_ALREADY_EXISTS——这就是判断“是否已有实例在运行”的经典手段。代码里WaitForSingleObject(hMutex, 5000)是带超时的等待5 秒拿不到锁就返回WAIT_TIMEOUT避免无限期阻塞。生产环境里的写法是循环重试或者直接失败退出。要注意如果程序在ReleaseMutex之前崩溃互斥量会因为内核对象被关闭而自动释放——这是 Win32 互斥量的一个隐性的可靠性设计但别依赖它正常流程务必成对释放。信号量Semaphore与互斥量的本质区别是它允许多个线程同时访问资源最多同时 N 个。常用于限流场景——比如限制最多同时下载 3 个文件。信号量的计数表示可用资源数量每WaitForSingleObject一次就减一每ReleaseSemaphore一次就加一。这是资源池、连接池、任务队列限流的基础组件。3.3 生产者-消费者模型的完整代码与参数说明生产者-消费者模型是线程同步的经典考题核心是两个线程共享一个固定大小的缓冲区用信号量统计“有空位”和“有数据”。这里给出一个简化实现生产者线程每 100ms 生成一个任务消费者取到任务后模拟处理。#include stdio.h #include process.h #include windows.h #define BUFFER_SIZE 8 // 环形缓冲区 static int buffer[BUFFER_SIZE]; static int head 0, tail 0; // 两个信号量空位数和数据数 HANDLE g_semEmpty; // 初始为 BUFFER_SIZE HANDLE g_semFull; // 初始为 0 // 关键段保护缓冲区索引 CRITICAL_SECTION g_cs; unsigned __stdcall producer(void* arg) { int val 0; for (int i 0; i 20; i) { val i 1; WaitForSingleObject(g_semEmpty, INFINITE); // 等待空位 EnterCriticalSection(g_cs); buffer[tail] val; tail (tail 1) % BUFFER_SIZE; LeaveCriticalSection(g_cs); ReleaseSemaphore(g_semFull, 1, NULL); // 数据量 1 printf(生产者放入 %d\n, val); Sleep(100); // 模拟生产时间 } return 0; } unsigned __stdcall consumer(void* arg) { for (int i 0; i 20; i) { WaitForSingleObject(g_semFull, INFINITE); // 等待有数据 EnterCriticalSection(g_cs); int val buffer[head]; head (head 1) % BUFFER_SIZE; LeaveCriticalSection(g_cs); ReleaseSemaphore(g_semEmpty, 1, NULL); // 空位数 1 printf(消费者取出 %d\n, val); Sleep(150); // 模拟处理时间 } return 0; } int main() { // 初始化 g_semEmpty CreateSemaphoreA(NULL, BUFFER_SIZE, BUFFER_SIZE, NULL); g_semFull CreateSemaphoreA(NULL, 0, BUFFER_SIZE, NULL); InitializeCriticalSection(g_cs); HANDLE threads[2]; threads[0] (HANDLE)_beginthreadex(NULL, 0, producer, NULL, 0, NULL); threads[1] (HANDLE)_beginthreadex(NULL, 0, consumer, NULL, 0, NULL); WaitForMultipleObjects(2, threads, TRUE, INFINITE); printf(所有生产消费完成\n); CloseHandle(g_semEmpty); CloseHandle(g_semFull); DeleteCriticalSection(g_cs); for (int i 0; i 2; i) CloseHandle(threads[i]); return 0; }逻辑说明这个模型里信号量本身已经保证了互斥——g_semEmpty初始为 8生产者最多同时推进 8 个任务g_semFull初始为 0消费者在没有数据时必须等待。保护buffer[tail]和head的关键段是为了防止两个线程同时操作缓冲区的索引变量导致错乱——虽然信号量已经约束了访问时机但生产者和消费者各自操作自己的tail和head这里的竞争风险要在代码里用锁隔离。参数说明CreateSemaphoreA(NULL, BUFFER_SIZE, BUFFER_SIZE, NULL)的第一个BUFFER_SIZE是初始计数第二个是最大计数。初始计数必须小于等于最大计数。释放信号量用ReleaseSemaphore第二个参数是释放数量一般用 1。这里有个容易踩的坑加锁顺序必须是“先 Wait 信号量再 Enter 关键段”反过来的话会死锁——比如消费者先加关键段再等信号量如果此时生产者拿到了关键段却因为缓冲区满而阻塞消费者永远等不到数据形成死锁。4. 多线程避坑指南死锁、崩溃与数据竞争的四个真实教训4.1 现象、原因、解决四个高频踩坑记录踩坑一界面卡死不是资源不够是主线程被阻塞。现象窗口程序点击按钮后整个窗体失去响应白屏、转圈只能任务管理器强制结束。 原因耗时操作直接写在窗口过程函数里消息循环被阻塞无法处理 WM_PAINT。 解决把耗时任务放工作线程工作线程完成后再通过PostMessage把结果传回主线程更新 UI。这里值得提醒的是——更新 UI 控件只能在主线程工作线程里直接改窗口标题会出怪问题轻则闪一下不刷新重则崩溃。正确做法是工作线程发PostMessage(WM_APP_USER, result, 0)主线程的WndProc里响应这个自定义消息。踩坑二数据竞争导致计数结果“莫名”不对还不一定每次都错。现象两个线程各执行 10000 次累加最终结果偶尔是 19999偶尔是 19980时好时坏。 原因g_counter非原子三指令读、加、写会被其他线程插队丢失更新。 解决加关键段或使用 Interlocked 函数。InterlockedIncrement(g_counter)是硬件级别的原子操作效率高于关键段适合简单计数的场景。这类问题用 Release 版比 Debug 版更容易暴露因为编译器优化让指令重排临界区变短导致交错更频繁。经验看到“结果不对但没崩溃”先怀疑数据竞争不要怀疑编译器。踩坑三线程参数传了栈地址主线程函数提前返回导致子线程读到垃圾值。现象线程函数打印的数字一会儿对一会儿不对甚至打印出乱码。 原因主线程把局部变量的地址传给_beginthreadex然后主线程继续执行并return局部变量所在栈帧被回收子线程再解引用这个地址访问的是被复用的栈内存。 解决用malloc在堆上分配参数结构体线程函数内free或者用全局变量数组按线程 id 分配。这个坑在循环里创建线程时尤其常见——每个线程拿到的都是同一个循环变量的地址结果所有线程读到同一个值。避免方法为每个线程分配独立的内存块或者把值直接强转到void*传进去前提是值不超过指针位宽。个人建议后者更简单比如(void*)(intptr_t)i线程函数里再(int)(intptr_t)arg转回来。踩坑四忘记关闭句柄导致句柄泄漏长时间运行后程序越来越卡。现象多线程服务程序运行数天后打开文件失败、无法新建线程重开程序后恢复。 原因每个_beginthreadex返回的句柄都是内核对象不经CloseHandle释放就会累积达到系统设定的句柄上限大约 10000 左右后就无法创建新对象。 解决建线程的地方统一记录HANDLE线程结束后立即CloseHandle如果只需要确定线程结束WaitForSingleObject(hThread, INFINITE)后再关闭。还有个隐性细节如果你只CreateThread不关句柄即使线程退出了内核对象也仍然占用内存。资源包里的代码模式是“创建-等待-关闭”三件套建议照着这个节奏写。4.2 死锁排查方法论先找锁顺序再找信号量残留死锁比数据竞争更难定位因为程序不是立即崩而是卡在某处不动。遇到死锁第一步是确认卡住的位置用调试器附加到进程选择“全部中断”查看每个线程的调用栈——如果一个线程停在WaitForSingleObject或EnterCriticalSection上另一个线程停在锁持有期间的Sleep或 I/O 上基本就是死锁了。从锁顺序入手排查把代码里所有获取锁的地方列出来看看是否存在相互等待。比如线程 A 持有锁 X 等待锁 Y线程 B 持有锁 Y 等待锁 X这就是经典的 ABBA 死锁。解决策略是“全局统一锁的获取顺序”所有线程都按 X→Y 的顺序拿锁。另一个手段是TryEnterCriticalSection非阻塞尝试拿不到就放弃重试而不是无限期等待。信号量残留是另一种隐蔽死锁信号量的初始计数设置错误比如把空位信号量初始设成 0生产者第一次WaitForSingleObject就永远阻塞。调试这类问题的办法是在关键行打日志打印信号量的当前计数值——ReleaseSemaphore的返回值能反映释放是否成功WaitForSingleObject的返回值能区分WAIT_TIMEOUT和WAIT_OBJECT_0。我用过最笨也最有效的方法每个线程进入和退出时各打一行日志对照日志时间线就能看出谁在等谁。不要依赖静态分析工具——小规模代码里printf比任何花哨工具都直观。5. 进阶线程池的简化实现与性能验证方法线程池解决的是“频繁创建销毁线程”的开销问题。Windows 下每个线程创建大约消耗几十微秒和 1MB 虚拟内存如果任务是高频短小的线程创建销毁的成本会超过任务本身。下面给出一个极简线程池固定 N 个工作线程用信号量通知任务到达。#include stdio.h #include process.h #include windows.h #define POOL_SIZE 3 #define TASK_COUNT 10 HANDLE g_semTask; CRITICAL_SECTION g_cs; int g_taskQueue[TASK_COUNT]; int g_taskHead 0, g_taskTail 0; unsigned __stdcall poolWorker(void* arg) { while (1) { WaitForSingleObject(g_semTask, INFINITE); // 等任务 EnterCriticalSection(g_cs); if (g_taskHead g_taskTail) { // 队列空可能被其他线程取完 LeaveCriticalSection(g_cs); continue; } int task g_taskQueue[g_taskHead % TASK_COUNT]; g_taskHead; LeaveCriticalSection(g_cs); printf(线程 %lu 执行任务 #%d\n, GetCurrentThreadId(), task); Sleep(50); // 模拟执行 } return 0; } int main() { g_semTask CreateSemaphoreA(NULL, 0, TASK_COUNT, NULL); InitializeCriticalSection(g_cs); // 创建固定数量工作线程 HANDLE threads[POOL_SIZE]; for (int i 0; i POOL_SIZE; i) { threads[i] (HANDLE)_beginthreadex(NULL, 0, poolWorker, NULL, 0, NULL); } // 提交任务 EnterCriticalSection(g_cs); for (int i 0; i TASK_COUNT; i, g_taskTail) { g_taskQueue[g_taskTail % TASK_COUNT] i 1; } LeaveCriticalSection(g_cs); ReleaseSemaphore(g_semTask, TASK_COUNT, NULL); // 让工作线程运行足够时间 Sleep(1000); printf(主线程结束工作线程仍存活简化版直接退出\n); return 0; }逻辑说明g_semTask的初始计数是 0每有一个有效任务就ReleaseSemaphore加一工作线程通过WaitForSingleObject消费信号量。这个简化版线程池有个已知限制——队列容量等于任务数量超出就丢任务实际线程池应该是无限队列或带拒绝策略。另一个限制是任务提交前加了锁但释放信号量在锁外这保证了信号量计数和队列长度一致如果你把ReleaseSemaphore放进临界区内可能出现信号量先增长但队列还没来得及写入的情况恰好让工作线程取到旧数据。验证线程池性能建议用QueryPerformanceCounter精确计时对比“每次任务新开线程”和“用线程池”两种方式的耗时。我自己的实测数据10 个任务、每个任务睡 50ms线程池带Sleep(1000)的总耗时约 1100ms其中 1000ms 是主线程硬等而反复创建线程的版本约 700ms——因为线程创建开销被并发执行掩盖了。只有任务数多、单任务执行短时比如 IO 请求线程池优势才明显。测试时注意第一次创建线程的冷启动开销建议先跑一两轮热身再统计。验证线程安全的有效方法是“高并发压力测试”创建 8 个线程、每个执行 10 万次累加观察结果是否精确等于预期。跑 100 轮只要有一轮结果不对就说明保护不到位。从那以后我每次写完多线程代码都强制走一遍这个流程先跑 50 轮压测验证数据一致性再检查句柄关闭逻辑最后用调试器挂住看有没有线程卡在等待上。这套流程帮我挡掉了至少三次线上事故。希望帮到你。本文还有配套的精品资源点击获取