使用 C 和 Win32 进行多线程编程

Microsoft C/C++ 编译器 (MSVC) 提供对创建多线程应用程序的支持。 如果应用程序需要执行会导致用户界面无响应的高开销操作,请考虑使用多个线程。

借助 MSVC,有通过多种方式使用多线程进行编程:可以使用 C++/WinRT 和 Windows 运行时库、Microsoft 基础类 (MFC) 库、C++/CLI 和 .NET 运行时,或 C 运行时库和 Win32 API。 本文介绍 C 中的多线程。有关示例代码,请参阅 C 中的示例多线程程序

多线程程序

简单而言,线程是通过程序的执行路径。 它也是 Win32 调度的最小执行单位。 线程由堆栈、CPU 寄存器状态和系统调度程序执行列表中的条目组成。 每个线程共享进程的所有资源。

一个进程由一个或多个线程,以及内存中程序的代码、数据和其他资源组成。 典型的程序资源是打开的文件、信号灯和动态分配的内存。 当系统调度程序为某个程序的一个线程授予执行控制权时,该程序就会执行。 调度程序确定哪些线程应该运行以及何时运行。 较低优先级的线程可能必须等待较高优先级的线程完成其任务。 在多处理器计算机上,调度程序可将各个线程移到不同的处理器以平衡 CPU 负载。

进程中的每个线程都是独立运行的。 除非使它们彼此可见,否则线程将单独执行,并且不知道进程中的其他线程。 但是,共享公用资源的线程必须使用信号灯或其他进程间通信方法来协调其工作。 有关同步线程的详细信息,请参阅编写多线程 Win32 程序

多线程编程的库支持

所有版本的 CRT 现在都支持多线程,但某些功能的非锁定版本除外。 有关详细信息,请参阅多线程库性能。 有关可与代码链接的 CRT 版本的信息,请参阅 CRT 库功能

多线程编程的包含文件

标准 CRT include 文件声明在库中实现的 C 运行时库函数。 如果编译器选项指定 __fastcall 或 __vectorcall 调用约定,则编译器假定应使用寄存器调用约定来调用所有函数。 运行时库函数使用 C 调用约定,标准 include 文件中的声明告知编译器生成对这些函数的正确外部引用。

用于线程控制的 CRT 函数

所有 Win32 程序都至少有一个线程。 任何线程可以创建额外的线程。 线程可以快速完成其工作然后终止,或者可以在程序的生命周期内保持活动状态。

CRT 库为线程创建和终止提供以下函数:_beginthread、_beginthreadex_endthread 和 _endthreadex

_beginthread_beginthreadex 函数创建一个新线程并在操作成功时返回一个线程标识符。 如果线程完成执行,则它会自动终止。 或者,它可以通过调用 _endthread_endthreadex 自行终止。

注意

如果从使用 libcmt.lib 生成的程序调用 C 运行时例程,则必须使用 _beginthread_beginthreadex 函数启动线程。 不要使用 Win32 函数 ExitThreadCreateThread。 当多个线程阻塞以等待暂停的线程完成对 C 运行时数据结构的访问时,使用 SuspendThread 会导致死锁。

_beginthread 和 _beginthreadex 函数

_beginthread_beginthreadex 函数创建一个新线程。 一个线程与进程中的其他线程共享该进程的代码和数据段,但具有自身独特的寄存器值、堆栈空间和当前指令地址。 系统将 CPU 时间分配到每个线程,以便进程中的所有线程可以并发执行。

_beginthread_beginthreadex 类似于 Win32 API 中的 CreateThread 函数,但存在以下差别:

  • 它们初始化特定的 C 运行时库变量。 仅当你在线程中使用 C 运行时库时,这一点才很重要。

  • CreateThread 帮助提供对安全属性的控制。 可以使用此函数来启动处于暂停状态的线程。

如果成功,_beginthread_beginthreadex 将返回新线程的句柄;如果出错,则返回错误代码。

_endthread 和 _endthreadex 函数

_endthread 函数终止 _beginthread 创建的线程(类似地,_endthreadex 终止 _beginthreadex 创建的线程)。 线程在完成时会自动终止。 _endthread_endthreadex 可用于从线程内部进行条件终止。 例如,如果专用于通信处理的线程无法控制通信端口,则可能会退出。

编写多线程 Win32 程序

编写具有多个线程的程序时,必须协调它们的行为以及程序资源的使用。 此外,请确保每个线程接收其自身的堆栈

在线程之间共享公用资源

注意

有关从 MFC 角度展开的类似讨论,请参阅多线程:编程提示多线程:何时使用同步类

每个线程有自身的堆栈和自身的 CPU 寄存器副本。 其他资源(例如文件、静态数据和堆内存)由进程中的所有线程共享。 使用这些公用资源的线程必须同步。 Win32 提供了多种同步资源的方式,包括信号灯、临界区、事件和互斥。

当多个线程访问静态数据时,程序必须为可能的资源冲突做好准备。 假设程序中的一个线程更新某个静态数据结构,该结构包含另一个线程显示的项的 x,y 坐标。 如果更新线程更改了 x 坐标,但在可以更改 y 坐标之前被抢占,则显示线程可能会调度在 y 坐标更新之前。 该项将显示在错误的位置。 要避免此问题,可以使用信号灯来控制对该结构的访问。

互斥(“互相排斥”的简写)是一种在彼此异步执行的线程或进程之间进行通信的方式。 这种通信可用于协调多个线程或进程的活动,协调方式通常是通过锁定和解锁资源来控制对共享资源的访问。 为了解决这种 x,y 坐标更新问题,更新线程在执行更新之前会设置一个互斥,指示数据结构已被使用。 在处理完这两个坐标后,它将清除互斥。 在更新显示画面之前,显示线程必须等待清除互斥。 这种等待互斥的过程通常称为互斥阻塞,因为进程已阻塞,在清除互斥之前无法继续

示例多线程 C 程序中所示的 Bounce.c 程序使用名为 ScreenMutex 的互斥来协调屏幕更新。 每当某个显示线程准备好写入屏幕时,它就会结合 ScreenMutex 的句柄和常量 INFINITE 来调用 WaitForSingleObject,以指示 WaitForSingleObject 调用应阻塞互斥而不是超时。如果 ScreenMutex 已清除,则 wait 函数将设置互斥,使其他线程不会干扰显示,并继续执行线程。 否则,在清除互斥之前,线程一直会阻塞。 当线程完成显示画面更新时,它会通过调用 ReleaseMutex 来释放互斥。

屏幕显示画面和静态数据不过是需要认真管理的资源中的两种。 例如,你的程序可能有多个线程访问同一个文件。 由于另一个线程可能已移动文件指针,因此每个线程必须在读取或写入之前重置文件指针。 此外,每个线程必须确保它在定位指针之后、访问文件之前的这段时间不会被抢占。 这些线程应使用信号灯来协调对文件的访问,方法是使用 WaitForSingleObjectReleaseMutex 调用并将每个文件访问括住。 以下代码示例演示了这种方法:

HANDLE    hIOMutex = CreateMutex (NULL, FALSE, NULL);

WaitForSingleObject( hIOMutex, INFINITE );
fseek( fp, desired_position, 0L );
fwrite( data, sizeof( data ), 1, fp );
ReleaseMutex( hIOMutex);

线程堆栈

应用程序的所有默认堆栈空间将分配到第一个执行线程,称为线程 1。 因此,必须指定要为程序所需的每个附加线程的单独堆栈分配多少内存。 如有必要,操作系统会为线程分配附加的堆栈空间,但你需要指定默认值。

_beginthread 调用中的第一个参数是指向 BounceProc 函数的指针,该函数执行线程。 第二个参数指定线程的默认堆栈大小。 最后一个参数是传递给 BounceProc 的 ID 编号。 BounceProc 使用该 ID 编号来播种随机数生成器,并选择线程的颜色属性和显示字符。

调用 C 运行时库或 Win32 API 的线程必须为它们调用的库和 API 函数留出足够的堆栈空间。 C printf 函数需要 500 字节以上的堆栈空间,调用 Win32 API 例程时应有 2K 字节的可用堆栈空间。

由于每个线程具有自身的堆栈,因此你可以使用尽可能少的静态数据来避免潜在的数据项冲突。 将程序设计为对可专用于线程的所有数据使用自动堆栈变量。 Bounce.c 程序中的全局变量只有互斥,或者初始化后永不更改的变量。

Win32 还提供线程本地存储 (TLS) 来存储每个线程的数据。 有关详细信息,请参阅线程本地存储 (TLS)

避免与多线程程序有关的问题

在创建、链接或执行多线程 C 程序时你可能会遇到多种问题。 下表描述了一些较常见问题。 (有关从 MFC 角度展开的类似讨论,请参阅多线程:编程提示。)

问题 可能的原因
出现一个消息框,指出程序导致了保护冲突。 有许多 Win32 编程错误会导致保护冲突。 保护冲突的一个常见原因是间接将数据分配到了 null 指针。 因为这会导致程序尝试访问不属于它的内存,因此会发生保护冲突。

若要检测保护冲突的原因,一种简单方法是使用调试信息编译程序,然后在 Visual Studio 环境中通过调试器运行该程序。 发生保护故障时,Windows 会将控制权转移给调试器,并且光标将定位在导致问题的行上。
程序生成大量的编译和链接错误。 可以通过将编译器的警告级别设置为其最高值之一并留意警告消息来消除许多潜在问题。 使用级别 3 或级别 4 警告级别选项,可以检测无意中进行了数据转换、缺少函数原型和使用了非 ANSI 功能的问题。

另请参阅

针对旧代码的多线程支持 (Visual C++)
C 中的示例多线程程序
线程本地存储 (TLS)
利用 C++/WinRT 实现的并发和异步操作
使用 C++ 和 MFC 进行多线程编程