目录
- 1 多线程的基本概念
- 1.1 进程
- 一、程序和进程的概念
- 二、进程组成
- 三、进程地址空间
- 1.2 线程
- 一、线程组成
- 二、线程运行
- 三、线程创建函数
- 1.3 多进程与多线程并发
- 一、多进程并发
- 二、多线程并发
- 2 线程同步
- 2.1 一个经典的线程同步问题
- 2.2 利用互斥对象实现线程同步
- 一、创建互斥对象
- 二、获得互斥对象所有权
- 三、释放互斥对象的所有权
- 四、如何利用互斥对象实现线程同步
- 2.3 利用互斥对象的代码实例
1 多线程的基本概念
1.1 进程
一、程序和进程的概念
程序是计算机指令的集合,它以文件的形式存储在磁盘上。而进程通常被定义为一个正在运行的程序的实例,是一个程序在其自身的地址空间中的一次执行活动。进程是资源申请、调度和独立运行的单位。
二、进程组成
进程由两部分组成:
1.操作系统用来管理进程的内核对象
内核对象是系统用来存放关于进程的统计信息的地方。内核对象是操作系统内部分配的一个内存块,该内存块是一种数据结构,其成员负责维护对象的各种信息。由于内核对象的数据结构只能被内核访问使用,因此应用程序在内存中无法找到该数据结构,并直接改变其内容,只能通过Windows提供的一些函数来对内核对象进行操作。
2.地址空间
它包含所有可执行模块或DLL模块的代码和数据。另外,它也包含动态内存分配的空间,例如线程的栈(stacks)和堆(heap)分配空间。进程从来不执行任何东西,它只是线程的容器。若要使进程完成某项操作,它必须拥有一个在它的环境中运行的线程,此线程负责执行包含在进程的地址空间中的代码。也就说,真正完成代码执行的是线程,而进程只是线程的容器,或者说线程的执行环境。
单个进程可以包含多个线程,这些线程都“同时”执行进程地址空间中的代码,每个进程至少拥有一个线程。当创建一个进程时,操作系统会自动创建这个进程的第一个线程,成为主线程,也就是执行main或者WinMain函数的线程。此后,主线程可以创建其他线程。
三、进程地址空间
系统赋予每个进程独立的虚拟地址空间。对于32位进程来说,这个空间地址是4GB。因为对32位指针来说,它能寻址的范围是2^32,即4GB。每个进程都有它自己的私有地址空间。
1.2 线程
一、线程组成
线程由两部分组成:
(1)线程的内核对象。操作系统用它来对线程实施管理。内核对象也是系统用来存放线程统计信息的地方。
(2)线程栈(stack)。它用于维护线程在执行代码时需要的所有函数参数和局部变量。
当创建线程时,系统创建一个线程内核对象。该线程内核对象不是线程本身,而是操作系统用来管理线程的较小的数据结构。可以将线程内核对象视为由关于线程的统计信息组成的一个小型数据结构。
线程总是在某个进程环境中创建。线程可以访问进程的内核对象的所有句柄、进程中的所有内存和在这个相同的进程中的所有其他线程的堆栈。这使得单个进程中的多个线程确实能够非常容易的通信。
二、线程运行
操作系统为每一个运行线程安排一定的CPU时间——时间片。系统通过一种循环的方式为线程提供时间片,线程在自己的时间片内运行,因时间片相当短,因此给用户的感觉就好像多个线程是同时运行一样。如果计算机拥有多个CPU,线程就能真正意义上同时运行了。
三、线程创建函数
创建线程可以使用系统提供的API函数:CreateThread来完成,该函数的原型声明如下所述:
HANDLE WINAPI CreateThread(
__in_opt LPSECURITY_ATTRIBUTES lpThreadAttributes,
__in SIZE_T dwStackSize,
__in LPTHREAD_START_ROUTINE lpStartAddress,
__in_opt LPVOID lpParameter,
__in DWORD dwCreationFlags,
__out_opt LPDWORD lpThreadId
);
- 返回值:返回线程句柄
“句柄” 类似指针, 但通过指针可读写对象, 通过句柄只是使用对象;有句柄的对象一般都是系统级别的对象(或叫内核对象);之所以给我们的是句柄而不是指针,目的只有一个:“安全”;似乎通过句柄能做很多事情, 但一般把句柄提交到某个函数(一般是系统函数)后,我们也就到此为止很难了解更多了。
不管是指针还是句柄, 都不过是内存中的一小块数据(一般用结构描述), 微软并没有公开句柄的结构细节, 猜一下它应该包括: 真实的指针地址、访问权限设置、引用计数等等.
既然 CreateThread 可以返回一个句柄,说明线程属于 “内核对象”。实际上不管线程属于哪个进程, 它们在系统中是平等的;在优先级相同的情况下, 系统会在相同的时间间隔内来运行一下每个线程, 不过这个间隔很小很小, 以至于让我们误以为程序是在不间断地运行。这时你应该有一个疑问: 系统在去执行其他线程的时候, 是怎么记住前一个线程的数据状态的?有这样一个结构 TContext, 它基本上是一个 CPU 寄存器的集合,线程是数据就是通过这个结构切换的。 - 参数2:堆栈大小
CreateThread 的第二个参数是分配给线程的堆栈大小。每个线程都有自己独立的堆栈(也拥有自己的消息队列)。什么是堆栈? 其实堆是堆、栈是栈, 有时 “栈” 也被叫做 “堆栈”。它们都是进程中的内存区域, 主要是存取方式不同(栈:先进后出; 堆:先进先出)。
现在我们知道了线程有自己的 “栈”, 并且在建立线程时可以分配栈的大小。如果这个值都是 0, 这表示使用系统默认的大小, 默认和主线程栈的大小一样, 如果不够用会自动增长;那主线程的栈有多大? 这个值是可以设定的: Project -> Options -> Linking
- 参数3:指向函数的指针
指向应用程序定义的LPTHREAD_START_ROUTINE类型的函数的指针,这个函数将由新线程执行,表明新线程的起始地址。线程入口函数的原型是:
DWORD WINAPI ThreadProc( __in LPVOID lpParameter);
请注意,线程入口函数是有返回值的,通过GetExitCodeThread 可以获取指定线程的终止状态码。 - 参数4:
线程入口函数的参数是个无类型指针(Pointer), 用它可以指定任何数据。 - 参数5:启动选项
CreateThread 的倒数第二个参数 dwCreationFlags(启动选项) 有两个可选值:
0: 线程建立后立即执行入口函数;
CREATE_SUSPENDED: 线程建立后会挂起等待。 - 参数6:输出线程ID
CreateThread 的最后一个参数是 “线程的 ID”;既然可以返回句柄, 为什么还要输出这个 ID? 原因是:
(1) 线程的 ID 是唯一的; 而句柄可能不只一个, 譬如可以用 GetCurrentThread 获取一个伪句柄、可以用 DuplicateHandle 复制一个句柄等等。
(2) ID 比句柄更轻便.在主线程中 GetCurrentThreadId、MainThreadID获取的都是主线程的 ID。
1.3 多进程与多线程并发
一、多进程并发
使用多进程并发是将一个应用程序划分为多个独立的进程(每个进程只有一个线程),这些独立的进程间可以互相通信,共同完成任务。由于操作系统对进程提供了大量的保护机制,以避免一个进程修改了另一个进程的数据,使用多进程比使用多线程更容易写出相对安全的代码。但是这也造就了多进程并发的两个缺点:
(1)在进程间的通信,无论是使用信号、套接字,还是文件、管道等方式,其使用要么比较复杂,要么就是速度较慢或者两者兼而有之。
(2)运行多个进程的开销很大,操作系统要分配很多的资源来对这些进程进行管理。
当多个进程并发完成同一个任务时,不可避免的是:操作同一个数据和进程间的相互通信,上述的两个缺点也就决定了多进程的并发并不是一个好的选择。所以就引入了多线程的并发。
二、多线程并发
多线程并发指的是在同一个进程中执行多个线程。
优点:有操作系统相关知识的应该知道,线程是轻量级的进程,每个线程可以独立的运行不同的指令序列,但是线程不独立的拥有资源,依赖于创建它的进程而存在。也就是说,同一进程中的多个线程共享相同的地址空间,可以访问进程中的大部分数据,指针和引用可以在线程间进行传递。这样,同一进程内的多个线程能够很方便的进行数据共享以及通信,也就比进程更适用于并发操作。
缺点:由于缺少操作系统提供的保护机制,在多线程共享数据及通信时,就需要程序员做更多的工作以保证对共享数据段的操作是以预想的操作顺序进行的,并且要极力的避免死锁(deadlock)。
由于以上原因,我们在编程中应当经常采用多线程来解决编程问题,尽量避免创建新的进程。
2 线程同步
2.1 一个经典的线程同步问题
多线程编程中,如果多个线程需要访问共享资源,就需要进行线程间的同步处理。那么什么是线程同步呢?线程同步是指当多个线程共享同一个资源,不会受到其他线程的干扰;或者说,线程同步指的是线程之间“协同”,即线程之间按照规定的先后次序运行。
线程同步的概念和其他“同步”概念不太一致,请区分线程同步和下面两个同步概念:
设备同步:在不同的设备之间规定一个共同的参考时间
数据库/文件同步:在不同的数据库之间保持数据一致
下面,我们创建一个多线程的程序,看看一个有趣的问题.
#include <iostream>
#include<windows.h>DWORD WINAPI Fun1Proc(__in LPVOID lpParameter);
DWORD WINAPI Fun2Proc(__in LPVOID lpParameter);int iIndx = 0;
int iTickets = 100;//创建进程的时候,系统会自动创建进程的第一个线程,成为主线程,也就是
//执行main函数的进程
int main()
{HANDLE hThread1;HANDLE hThread2;//创建线程hThread1 = CreateThread(NULL, 0, Fun1Proc, NULL, 0, NULL);hThread2 = CreateThread(NULL, 0, Fun2Proc, NULL, 0, NULL);CloseHandle(hThread1);CloseHandle(hThread2);while (true)if (iTickets > 0)Sleep(100);else{getchar();return 0;}
}//
DWORD WINAPI Fun1Proc(__in LPVOID lpParameter)
{while (true){if (iTickets > 0)std::cout << "thread1 sell tickets:" << iTickets-- << std::endl;elsebreak;}return 0;
}DWORD WINAPI Fun2Proc(__in LPVOID lpParameter)
{while (true){if (iTickets > 0)std::cout << "thread2 sell tickets:" << iTickets-- << std::endl;elsebreak;}return 0;
}
首先,在main函数中我们创建了两个线程,线程的入口函数分别是Fun1Proc和Fun2Proc,在调用CreateThread函数时,第5个参数设置为0,表明线程立即执行。
这里需要注意一点的是,我们在main函数中调用了CloseHandle;可能有人要问,为什么刚刚创建了线程,现在又关闭了呢?原因是CloseHandle并没有终止新创建的线程的执行,而仅仅表示主进程对新创建的线程的引用不感兴趣,因此将它关闭。另一方面,当关闭该句柄时,系统会递减该线程内核对象的使用计数。当创建的这个新线程执行完毕以后,系统也会递减该线程内核对象的使用计数。当使用计数为0时,系统就会释放该线程内核对象。如果没有关闭线程句柄,系统会一致保持着对线程内核对象的引用,这样,即使该线程执行完毕,它的引用计数仍不会为0。这样该线程内核对象也就不会被释放,只有等到进程终止时,系统才会清理这些残留的对象。
运行这段代码,结果如下:
在图中,竟然出现了线程2卖了倒数第14张票,紧接着线程1卖了倒数第15张票的情况,这说明两个线程函数在访问iTickets全局变量时,出现了同步问题。具体来说:当线程1卖倒数第15张票的时候,正准备要打印到屏幕上;然后此时系统将时间片交给了线程2,线程2卖了倒数第14张票,并且顺利打印;恰在此时,系统又将流程切换到了流程1,流程1继续打印卖了倒数第15张票这个信息。
以上问题实际上是经典的线程同步问题,我们要让“线程之间“协同”,即线程之间按照规定的先后次序运行”,否则对共享资源的访问就可能出错。
2.2 利用互斥对象实现线程同步
为了解决上述线程同步问题,我们可以使用互斥对象。
互斥对象(mutex)属于内核对象,它能确保线程拥有单个资源的互斥访问权。互斥对象包含一个使用数量,一个线程ID和一个计数器。其中ID用于标识系统中哪个线程当前拥有互斥对象,计数器用于指明该线程拥有互斥对象的次数。
一、创建互斥对象
创建互斥对象的函数为
HANDLE WINAPI CreateMutex(
__in_opt LPSECURITY_ATTRIBUTES lpMutexAttributes,
__in BOOL bInitialOwner,
__in_opt LPCTSTR lpName
);
lpMutexAttributes:可以给该对象传递NULL值,让互斥对象使用默认的安全性。
bInitialOwner:BOOL类型,指定互斥对象的初始拥有者。如果为真,则创建这个互斥对象的线程获得该对象的所有权;否则,该线程将不获得所创建的互斥对象的所有权。
lpName是互斥对象的名称,如果参数为NULL,则创建了一个匿名对象。
在互斥对象中,有一个重要概念:如果所有线程都不拥有互斥对象,那么就称互斥对象处于有信号状态;但凡某个线程拥有了互斥对象的所有权,就称互斥对象处于无信号状态。
二、获得互斥对象所有权
线程必须主动请求互斥对象的使用权才可能获得该所有权,这可以通过下面函数实现
DWORD WINAPI WaitForSingleObject(__in HANDLE hHandle,__in DWORD dwMilliseconds
);
hHandle:所请求对象的句柄。一旦互斥对象处于有信号状态,则该函数就返回。如果互斥对象始终处于无信号的状态,则该函数就会一直等待,这样就会暂停线程的执行。
dwMilliseconds:指定的等待时间间隔,以毫秒为单位。如果指定的时间间隔已过,即使所请求的对象仍处于无信号状态,WaitForSingleObject函数也会返回。如果此参数设置为0,那么函数将测试该对象的状态并立刻返回;如果此参数设置为INFINITE,则该函数会永远等待,直到等待的对象处于有信号状态才会返回。
三、释放互斥对象的所有权
当线程对共享资源访问结束后,应释放该互斥对象的所有权,也就是让互斥对象处于有信号状态,这可以通过下面函数实现
BOOL WINAPI ReleaseMutex( __in HANDLE hMutex);
ReleaseMutex函数只有一个HANDLE类型的参数,即需要释放的互斥对象的句柄。该函数的返回值是BOOL类型,如果函数调用成功,返回非0值,否则返回0值。
另外需要注意,对互斥对象来讲,哪个线程拥有所有权,哪个线程才能释放这个对象。比如说A线程拥有互斥对象,你就不能在B线程内释放这个对象。另外操作系统一旦发现某个线程已经终止,它就会自动将该线程所拥有的互斥对象的线程ID设为0,并将其计数归0。
四、如何利用互斥对象实现线程同步
基本思路是这样的:在程序中创建一个全局的互斥对象(当然,你可以使用单例模式,将全局对象封装在函数中,转为局部静态变量),在每个线程中,如果有需要访问的共享资源,就在访问共享资源之前通过WaitForSingleObject获得互斥对象的所有权,在访问之后,再用ReleaseMutex释放互斥对象的所有权。这样,每个线程在访问共享资源时,都会“排斥”其他线程获得互斥对象所有权,只有等这个线程访问完毕后,下一个线程才能获得互斥对象所有权,才能进来访问共享资源。这样,也就实现了线程同步了。
2.3 利用互斥对象的代码实例
接下来,让我们看看利用互斥对象的具体使用例程
#include <iostream>
#include<windows.h>DWORD WINAPI Fun1Proc(__in LPVOID lpParameter);
DWORD WINAPI Fun2Proc(__in LPVOID lpParameter);int iIndx = 0;
int iTickets = 100;
HANDLE hMutex;//创建进程的时候,系统会自动创建进程的第一个线程,成为主线程,也就是
//执行main函数的进程
int main()
{HANDLE hThread1;HANDLE hThread2;//创建互斥对象hMutex = CreateMutex(NULL, FALSE, NULL);//创建线程hThread1 = CreateThread(NULL, 0, Fun1Proc, NULL, 0, NULL);hThread2 = CreateThread(NULL, 0, Fun2Proc, NULL, 0, NULL);CloseHandle(hThread1);CloseHandle(hThread2);while (true){if (iTickets > 0)Sleep(100);else{getchar();return 0;}}
}//
DWORD WINAPI Fun1Proc(__in LPVOID lpParameter)
{while (true){WaitForSingleObject(hMutex, INFINITE);if (iTickets > 0){std::cout << "thread1 sell tickets:" << iTickets-- << std::endl;}elsebreak;ReleaseMutex(hMutex);}return 0;
}DWORD WINAPI Fun2Proc(__in LPVOID lpParameter)
{while (true){WaitForSingleObject(hMutex, INFINITE);if (iTickets > 0){std::cout << "thread2 sell tickets:" << iTickets-- << std::endl;}elsebreak;ReleaseMutex(hMutex);}return 0;
}
在上述main函数中,我们首先用CreateMutex创建了互斥对象,请注意其第二个参数为FALSE,所以主线程不拥有该对象。然后,在每个线程函数中都使用了WaitForSingleObject\ReleaseMutex完成了各线程对共享资源的同步访问要求。我们来看结果,同步问题已经解决。
下一节,我们将介绍:Windows中多线程的基础知识——2事件对象。