65.9K
CodeProject 正在变化。 阅读更多。
Home

多线程教程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (87投票s)

2006年7月10日

CPOL

20分钟阅读

viewsIcon

611982

downloadIcon

13130

本文演示了如何使用纯 Win32 API 在 C++ 中编写多线程 Windows 程序。

背景

当您在提供内存保护的操作系统(如 Windows 和 UNIX/Linux)上运行两个程序时,这两个程序会作为单独的进程执行,这意味着它们被分配了独立的地址空间。这意味着当程序 #1 修改其内存空间中的地址 0x800A 1234 时,程序 #2 的地址 0x800A 1234 中的内容不会发生任何变化。对于无法实现进程分离的较简单的操作系统,一个有缺陷的程序不仅会使自身崩溃,还会导致计算机上的其他程序(包括操作系统本身)崩溃。

同时执行一个以上进程的能力称为多进程。进程由程序(通常称为应用程序)组成,其语句在独立的内存区域中执行。有一个程序计数器用于记住下一个要执行的语句,有一个堆栈用于存储传递给函数的参数以及函数中的局部变量,还有一个用于存储程序的其余内存需求。堆用于必须比单个函数生命周期更长的内存分配。在 C 语言中,您使用 malloc 从堆中获取内存;在 C++ 中,您使用 new 关键字。

有时,安排两个或多个进程协同工作以完成一个目标是有益的。计算机硬件提供多个处理器时,这种情况就很有益。过去,这意味着主板上有两个插槽,每个插槽都装有一个昂贵的 Xeon 芯片。得益于 VLSI 集成的进步,现在这两个处理器芯片可以集成在单个封装中。例如 Intel 的“Core Duo”和 AMD 的“Athlon 64 X2”。如果您希望两个微处理器同时处理一个目标,您基本上有两种选择:

  1. 设计您的程序以使用多个进程(通常意味着多个程序),或者
  2. 设计您的程序以使用多个线程

那么,什么是线程?线程是将工作负载拆分为独立执行流的另一种机制。线程比进程更轻量级。这意味着它不如完整的进程灵活,但由于操作系统需要设置的东西较少,因此可以更快地启动。缺少什么?缺少的是独立的地址空间。当一个程序包含两个或多个线程时,所有线程共享单个内存空间。如果一个线程修改了地址 0x800A 1234 的内容,那么所有其他线程会立即看到其地址 0x800A 1234 的内容发生变化。此外,所有线程共享单个堆。如果一个线程(通过 mallocnew)分配了堆中所有可用内存,那么其他线程的附加分配尝试将失败。

但是每个线程都有自己的堆栈。这意味着,线程 #1 可以同时调用 FunctionWhichComputesALot(),而线程 #2 可以同时调用 FunctionWhichDrawsOnTheScreen()。这两个函数都写在同一个程序中。只有一个程序。但是,有独立的执行线程在这个程序中运行。

优点是什么?嗯,如果您的计算机硬件提供两个处理器,那么两个线程可以同时运行。即使在单处理器上,多线程也可以提供优势。大多数程序在需要访问硬盘之前无法执行很多语句。这是一个非常慢的操作,因此操作系统在等待期间会将程序置于睡眠状态。事实上,操作系统会在等待期间将计算机硬件资源分配给别人的程序。但是,如果您编写了一个多线程程序,那么当您的一个线程停滞时,您的其他线程可以继续。

Jaeschke 的杂志文章

学习任何新的编程概念的一个好方法是研究他人的代码。您可以在杂志文章中找到源代码,也可以在 CodeProject 等网站上在 Internet 上找到。我在 Rex Jaeschke 为《C/C++ Users Journal》撰写的两篇文章中遇到了一些多线程程序的优秀示例。在 2005 年 10 月刊中,Jaeschke 撰写了一篇题为“C++/CLI 线程:第一部分”的文章,在 2005 年 11 月刊中,他撰写了后续文章“C++/CLI 线程:第二部分”。不幸的是,《C/C++ Users Journal》杂志在这些文章发表后不久就停刊了。但是,原始文章和 Jaeschke 的源代码仍然可以在以下网站上找到:

您会注意到,已停刊的《C/C++ Users Journal》的内容已整合到 Dr. Dobb's Portal 网站,该网站与《Dr. Dobb's Journal》相关联,这是另一本优秀的编程杂志。

您可能不熟悉 C++/CLI 的表示法。它代表“C++ 公共语言基础设施”,是微软的一项发明。您可能熟悉 Java 和 C#,这两种语言都提供托管代码,其中操作系统而不是程序员负责取消分配从堆中进行的所有内存分配。C++/CLI 是微软提出的向 C++ 语言添加托管代码的方案。

我不是这种方法的粉丝,所以对 Jaeschke 的原始源代码不太感兴趣。我相信 Java 和 C# 会继续存在,但 C++/CLI 试图在已经非常复杂的 C++ 语言之上添加太多的新表示法(和概念),我认为这种语言将消失。

但是,我仍然阅读了原始的《C/C++ Users Journal》文章,并认为 Jaeschke 选择的多线程示例很好。我特别喜欢他的示例程序都很短,但在没有用于成功线程间通信所需的同步方法的情况下运行时会显示数据损坏。因此,我坐下来用标准 C++ 重写了他的程序。这正是我现在与您分享的。我提供的源代码也可以用标准 C 编写。事实上,这比用 C++ 完成更容易,原因将在稍后介绍。

现在可能是阅读 Jaeschke 原始文章的合适时机,因为我不打算重复他对多任务处理重入性原子性等的精彩解释。例如,我也不打算解释程序如何自动获得第一个线程,以及所有其他线程必须由程序显式创建(哎呀)。您可以在上面找到 Jaeschke 两篇文章的 URL。

在 Windows 下创建线程

不幸的是,C++ 语言没有标准化创建线程的方法。因此,各种编译器供应商都发明了自己的解决方案。如果您要编写在 Windows 下运行的程序,那么您将需要使用 Win32 API 来创建线程。我将演示这一点。Win32 API 提供了以下函数来创建新线程:

uintptr_t _beginthread( 
   void( __cdecl *start_address )( void * ),
   unsigned stack_size,
   void *arglist 
);

此函数签名可能看起来令人生畏,但使用起来很简单。_beginthread() 函数接受三个参数。第一个是要让新线程开始执行的函数的名称。这称为线程的入口点函数。您需要编写这个函数,并且唯一的要求是它接受一个参数(类型为 void*)并且不返回值。这就是函数签名所指的含义。

void( __cdecl *start_address )( void * ),

传递给 _beginthread() 函数的第二个参数是新线程请求的堆栈大小(记住,每个线程都有自己的堆栈)。但是,我总是将此参数设置为 0,这会迫使 Windows 操作系统为我选择堆栈大小,并且我从未遇到过此方法的问题。传递给 _beginthread() 函数的最后一个参数是您希望传递给入口点函数的单个参数。下面的示例程序将对此进行说明:

#include <stdio.h>
#include <windows.h>
#include <process.h>     // needed for _beginthread()

void  silly( void * );   // function prototype

int main()
{
    // Our program's first thread starts in the main() function.

    printf( "Now in the main() function.\n" );

    // Let's now create our second thread and ask it to start
    // in the silly() function.


    _beginthread( silly, 0, (void*)12 );

    // From here on there are two separate threads executing
    // our one program.

    // This main thread can call the silly() function if it wants to.

    silly( (void*)-5 );
    Sleep( 100 );
}

void  silly( void *arg )
{
    printf( "The silly() function was passed %d\n", (INT_PTR)arg ) ;
}

请继续编译此程序。只需在 Visual C++ .NET 2003 的新建项目向导中请求一个Win32 控制台程序,然后“添加新项”,该项是一个 C++ 源文件(.CPP 文件),您可以在其中放置我展示的语句。我提供了 Jaeschke 的(修改后的)程序的 Visual C++ .NET 2003 工作区,但您需要知道从头开始创建多线程程序的关键:您必须记住对新建项目向导提供的默认项目属性进行一项修改。即,您必须打开“项目属性”对话框(在 Visual C++ 主菜单中选择“项目”,然后选择“属性”)。在此对话框的左侧列中,您将看到一个名为“配置属性”的树状视图控件,主要子节点标记为“C/C++”、“链接器”等。双击“C/C++”节点将其展开。然后,单击“代码生成”。在“项目属性”对话框的右侧区域,您现在将看到列出的“运行时库”。它默认为“单线程调试 (/MLd)”。[表示法 /MLd 表示可以通过命令行使用 /MLd 开关来实现此选择。] 您需要单击此条目以查看下拉列表控件,从中必须选择多线程调试 (/MTd)。如果忘记执行此操作,您的程序将无法编译,并且错误消息将抱怨 _beginthread() 标识符。

如果您注释掉此示例程序中看到的 Sleep() 函数调用,将会发生一件非常有趣的事情。如果没有 Sleep() 语句,程序的输出可能只显示对 silly() 函数的一次调用,并且传递的参数为 -5。这是因为当主线程到达 main() 函数末尾时,程序的进程将终止,并且这可能发生在操作系统有机会为此进程创建其他线程之前。这是 Jaeschke 关于 C++/CLI 所说的内容的一个差异。显然,在 C++/CLI 中,每个线程都有独立的生命周期,而整个进程(所有线程的容器)将持续存在直到最后一个线程决定退出。对于纯 C++ Win32 程序则不是这样:进程在主线程(在 main 函数中启动的那个)死亡时死亡。此线程的死亡意味着所有其他线程的死亡。

将 C++ 成员函数用作线程的入口点函数

我刚刚列出的示例程序实际上并不是 C++ 程序,因为它不使用任何类。它只是一个 C 语言程序。Win32 API 最初是为 C 语言设计的,当您在 C++ 程序中使用它时,有时会遇到困难。例如,有这个困难:“如何将类成员函数(又名实例函数)用作线程的入口点函数?”

如果您对 C++ 不熟悉,让我提醒您这个问题。每个 C++ 成员函数都有一个隐藏的第一个参数,称为 this 参数。通过 this 参数,函数知道要操作哪个类的实例。因为您从看不到这些 this 参数,所以很容易忘记它们的存在。

现在,让我们再次考虑 _beginthread() 函数,它允许我们为新线程指定任意入口点函数。此入口点函数必须接受一个 void* 参数。是的,这就是问题所在。_beginthread() 所需的函数签名不允许隐藏的 this 参数,因此 C++ 成员函数不能直接由 _beginthread() 激活。

如果我们不是因为 C 和 C++ 是极其富有表现力的语言(以允许您随意犯错而闻名),并且 _beginthread() 允许我们为入口点函数指定任意参数,那么我们将陷入困境。因此,我们采用两步程序来实现我们的目标:我们要求 _beginthread() 调用一个静态类成员函数(与实例函数不同,它没有隐藏的 this 参数),并将此静态类函数作为 void* 发送隐藏的 this 指针。静态类函数知道如何将 void* 参数转换为类实例的指针。瞧!我们现在知道哪个类实例应该调用实际的入口点函数,并且此调用完成了两步过程。相关代码(来自 Jaeschke 修改的第 1 部分列表 1 程序)如下所示:

class ThreadX
{
public:

  // In C++ you must employ a free (C) function or a static
  // class member function as the thread entry-point-function.

  static unsigned __stdcall ThreadStaticEntryPoint(void * pThis)
  {
      ThreadX * pthX = (ThreadX*)pThis;   // the tricky cast

      pthX->ThreadEntryPoint();    // now call the true entry-point-function

      // A thread terminates automatically if it completes execution,
      // or it can terminate itself with a call to _endthread().

      return 1;          // the thread exit code
  }

  void ThreadEntryPoint()
  {
     // This is the desired entry-point-function but to get
     // here we have to use a 2 step procedure involving
     // the ThreadStaticEntryPoint() function.

  }
}

然后,在 main() 函数中,我们开始两步过程,如下所示:

hth1 = (HANDLE)_beginthreadex( NULL, // security
                      0,             // stack size
                      ThreadX::ThreadStaticEntryPoint,// entry-point-function
                      o1,           // arg list holding the "this" pointer
                      CREATE_SUSPENDED, // so we can later call ResumeThread()
                      &uiThread1ID );

请注意,我正在使用 _beginthreadex() 而不是 _beginthread() 来创建我的线程。“ex”代表“extended”,这意味着此版本提供了 _beginthread() 所不具备的额外功能。这是 Microsoft Win32 API 的典型做法:当发现不足之处时,会引入更强大的增强技术。这些新扩展功能之一是 _beginthreadex() 函数允许我创建但不实际启动我的线程。我选择此选项仅是为了使我的程序更符合 Jaeschke 的 C++/CLI 代码。此外,_beginthreadex() 允许入口点函数返回一个无符号值,这对于向线程创建者报告状态很有用。线程的创建者可以通过调用 GetExitCodeThread() 来访问此状态。这在提供的“第 1 部分列表 1”程序中都有演示(名称来自 Jaeschke 的杂志文章)。

main() 函数的末尾,您将看到一些语句,它们在 Jaeschke 的原始程序中没有对应的语句。这是因为在 C++/CLI 中,进程会一直运行直到最后一个线程退出。也就是说,线程具有独立的生命周期。因此,Jaeschke 的原始代码旨在表明主线程可以退出而不会影响其他线程。但是,在 C++ 中,进程在主线程退出时终止,并且当进程终止时,其所有线程也会随之终止。我们通过以下语句强制主线程(在 main() 函数中启动的线程)等待其他两个线程:

    WaitForSingleObject( hth1, INFINITE );
    WaitForSingleObject( hth2, INFINITE );

如果注释掉这些等待,非主线程将永远没有机会运行,因为当主线程到达 main() 函数末尾时,进程将终止。

线程间的同步

在第 1 部分列表 1 程序中,多个线程不相互交互,因此它们不会损坏彼此的数据。第 1 部分列表 2 程序的目的是演示这种损坏是如何发生的。这种类型的损坏非常难以调试,如果您不正确设计多线程程序,这会使多线程程序非常耗时。关键在于在访问共享数据(写入或读取)时提供同步

同步对象是一个对象,其句柄可以在 Win32 的等待函数之一(如 WaitForSingleObject())中指定。Win32 提供的同步对象有:

  • 事件
  • 互斥体或临界区
  • 信号量
  • 可等待定时器

事件会通知一个或多个等待线程某个事件已发生。

互斥体一次只能由一个线程拥有,从而使线程能够协调对共享资源的互斥访问。当互斥体对象未被任何线程拥有时,其状态设置为已发出信号;当被线程拥有时,则设置为非信号状态。一次只能有一个线程拥有互斥体对象,该名称来源于它在协调共享资源的互斥访问方面很有用。

临界区对象提供与互斥体对象类似的同步,不同之处在于临界区对象只能由单个进程的线程使用(因此它们比互斥体更轻量级)。与互斥体对象一样,临界区对象一次只能由一个线程拥有,这对于保护共享资源免受并发访问非常有用。关于线程获得临界区所有权的顺序没有保证;但是,操作系统会对所有线程公平。互斥体和临界区之间的另一个区别是,如果临界区对象当前由另一个线程拥有,EnterCriticalSection() 会无限期等待所有权,而 WaitForSingleObject()(与互斥体一起使用)允许您指定超时。

信号量维护一个介于零和某个最大值之间的计数,限制同时访问共享资源的线程数量。

可等待定时器通知一个或多个等待线程指定的时间已到。

此第 1 部分列表 2 程序演示了临界区同步对象。现在看一下源代码。请注意,在 main() 函数中,我们创建了两个线程,并让它们都使用相同的入口点函数,即 StartUp() 函数。但是,因为两个对象实例(o1o2)的mover 类数据成员具有不同的值,所以两个线程的表现完全不同。由于一种情况是 isMover = true,另一种情况是 isMover = false,因此一个线程不断更改 Point 对象的 xy 值,而另一个线程仅显示这些值。但是,这足以发生交互,以至于程序在使用同步时会出现错误。

按我提供的编译和运行程序,看看问题。偶尔,x 和 y 值的输出会显示 x 和 y 值之间的差异。发生这种情况时,x 值将比 y 值大 1。这是因为在 x 值递增和 y 值递增之间,更新 x 和 y 的线程被显示值的线程中断了。

现在,转到 Main.cpp 文件的顶部,找到以下语句:

//#define WITH_SYNCHRONIZATION

取消注释此语句(即删除双斜杠)。然后,重新编译并重新运行程序。现在它工作得很完美。这一项更改激活了程序中的所有临界区语句。我也可以使用互斥体或信号量,但临界区是 Windows 提供的最轻量级(因此最快)的同步对象。

生产者/消费者模式

多线程架构最常见的用途之一是熟悉的生产者/消费者情况,其中有一个活动用于创建数据包,另一个活动用于接收和处理这些数据包。下一个示例程序来自 Jaeschke 的第 2 部分列表 1 程序。CreateMessages 类的实例充当生产者,ProcessMessages 类的实例充当消费者。生产者创建五个消息然后自毁。消费者设计为无限期运行,直到被命令停止。主线程等待生产者线程退出,然后命令消费者线程退出。

程序有一个 MessageBuffer 类的单个实例,该实例由生产者和消费者线程共享。通过同步语句,此程序确保在生产者线程将某物放入消息缓冲区之前,消费者线程无法处理其内容;并且在前一个消息被消耗之前,生产者线程无法放入另一个消息。

由于我的第 1 部分列表 2 程序演示了临界区,因此我选择在此第 2 部分列表 1 程序中使用互斥体。与第 1 部分列表 2 示例程序一样,如果您只是按我提供的编译和运行第 2 部分列表 1 程序,您会发现它存在错误。虽然生产者创建了以下五个消息:

1111111111
2222222222
3333333333
4444444444
5555555555

消费者收到了以下五个消息:

1
2111111111
3222222222
4333333333
5444444444

显然存在同步问题:消费者在生产者更新新消息的第一个字符后立即访问消息缓冲区。但消息缓冲区的其余部分尚未更新。

现在,转到 Main.cpp 文件的顶部,找到以下语句:

//#define WITH_SYNCHRONIZATION

取消注释此语句(即删除双斜杠)。然后,重新编译并重新运行程序。现在它工作得很完美。

在 Jaeschke 原始杂志文章的英文解释和我放在 C++ 源代码中的所有注释之间,您应该能够理解流程。我将要做的最后一个评论是 GetExitCodeThread() 函数在线程仍然存活时(因此尚未真正退出)返回特殊值 259。您可以在 WinBase 头文件中找到此值的定义:

#define STILL_ACTIVE   STATUS_PENDING

您可以在 WinNT.h 头文件中找到 STATUS_PENDING 的定义:

#define STATUS_PENDING    ((DWORD   )0x00000103L)

请注意 0x00000103 = 259。

线程局部存储

Jaeschke 的第 2 部分列表 3 程序演示了线程局部存储。线程局部存储是仅可供单个线程访问的内存。在本篇文章的开头,我说操作系统可以比创建新进程更快地启动一个新线程,因为所有线程共享相同的内存空间(包括堆),因此操作系统在创建新线程时需要设置的内容较少。但是,这里是该规则的例外。当您请求线程局部存储时,您就是在要求操作系统在某些内存位置周围竖起一堵墙,以便只有一个线程可以访问该内存。

声明变量应使用线程局部存储的 C++ 关键字是 __declspec(thread)

与我其他示例程序一样,此程序在不更改的情况下编译和运行将显示明显的同步问题。在您看到问题后,转到 Main.cpp 文件的顶部,找到以下语句:

//#define WITH_SYNCHRONIZATION

取消注释此语句(即删除双斜杠)。然后,重新编译并重新运行程序。现在它工作得很完美。

原子性

Jaeschke 的第 2 部分列表 4 程序演示了原子性问题,即操作在中间被中断时会失败的情况。这个“原子”这个词的用法可以追溯到原子被认为是物质的最小粒子,因此是无法进一步分裂的东西。汇编语言语句自然是原子的:它们无法在中间被中断。高级 C 或 C++ 语句则不然。虽然您可能认为对 64 位变量的更新是原子操作,但在 32 位硬件上实际上并非如此。Microsoft 的 Win32 API 提供了 InterlockedIncrement() 函数作为解决此类原子性问题的方案。

如果此示例程序只需要在 Windows 2003 Server 上运行,那么它可以重写为使用 64 位整数(LONGLONG 数据类型)和 InterlockedIncrement64() 函数。但是, alas,Windows XP 不支持 InterlockedIncrement64()。因此,我最初担心我无法在仅处理 32 位整数的 Windows XP 程序中演示原子性错误。但是,奇怪的是,只要我们使用 Visual C++ .NET 2003 编译器的调试模式设置而不是发布模式设置,就可以演示这种错误。因此,您会注意到,与其他分布在 .ZIP 文件中的示例程序不同,这个程序设置为调试配置。

与我其他示例程序一样,此程序在不更改的情况下编译和运行将显示明显的同步问题。在您看到问题后,转到 Main.cpp 文件的顶部,找到以下语句:

static bool interlocked = false;    // change this to fix the problem

false 更改为 true,然后重新编译并重新运行程序。现在它工作得很完美,因为它现在使用了 InterlockedIncrement()

示例程序

为了让其他 C++ 程序员能够试验这些多线程示例,我提供了一个包含五个 Visual C++ .NET 2003 工作区的 .ZIP 文件,用于 Jaeschke 原始文章(现已翻译成 C++)的第 1 部分列表 1、第 1 部分列表 2、第 2 部分列表 1、第 2 部分列表 3 和第 2 部分列表 4 程序。尽情享用!

结论

这是我提交给 CodeProject 的第二篇文章。第一篇演示了如何使用 Direct3D 8 对 Munsell 色彩实体进行建模,以便您可以像在视频游戏中一样在这三维色彩立方体中飞行。我还有一个网站,提供完整的编程入门,包括汇编语言编程。我的主页是 www.computersciencelab.com

多线程教程 - CodeProject - 代码之家
© . All rights reserved.