标签(空格分隔): Windows multithread programming 多线程 并发 编程
背景知识
在开始学习多线程编程之前,先来学习下进程和线程
进程
进程是指具有一定独立功能的程序在某个数据集合上的一次运行活动,是系统进行资源分配和调度运行的一个基本单位。简单地说,进程是程序在计算机上的一次执行活动,当你启动了一个程序,你就启动了一个进程,退出一个程序,也就结束了一个进程。
打开windows任务管理器-->详细信息,可以看到Windows系统下有很多进程在运行。
注意:
程序并不等于进程。程序只是一组指令的有序集合,它本身没有任何运行的含义,只是一个静态实体。进程是程序在某个数据集上的执行,是一个动态实体。
程序只有被装入内存后才能运行,程序一旦进入到内存就成为进城了,因此,进程的创建过程也就是程序从外存储器(硬盘或者网卡)被加载到内存的过程。进程因创建而产生,因调度而运行,因等待资源或事件而处于等待状态,因完成任务而销毁,它反映了一个程序在一定的数据集上运行的全部动态过程。
进程在其存在过程中,由于多个进程的并发执行,受到CPU、外部设备等资源的制约,使得它们的状态不断发生变化。进程的基本状态有三种:就绪状态、运行状态、阻塞状态。三种状态可以相互转化。
- 就绪状态:进程获得除了CPU之外的一切运行所需的资源,等待获得CPU,一旦获得CPU即可立即运行。(如数据已经准备好,或者接收到有新数据,需要CPU来处理)
- 运行状态:进程获得了包括CPU在内的一切资源,正在CPU上运行。(正在运行指令,处理数据)
- 阻塞状态:正在CPU上运行的进程,由于某种原因,不再具备运行的条件
而暂时停止运行。(比如需要等待I/O完成、当前进程的CPU时间片耗尽、等待其他进程发来消息、等待用户完成输入等)。
进程调度:当就绪进程的数目多于CPU的数目时,需要按照一定的算法动态地将CPU分配给就绪进程队列中的某一个进程,并使之运行,这就是所谓的进程调度。
当分配给某个进程的运行时间(时间片)用完了时,进程就会有运行状态回到就绪状态。运行中的进程如果需要执行I/O操作,比如从键盘输入数据,就会进入到阻塞状态等待I/O操作完成,I/O操作完成后,就会转入就绪状态等待下一次调度。
进程调度的关键是进程调度算法。进程调度算法解决两个问题:一是当CPU空闲时,选择哪个就绪进程运行;二是进程占有CPU后,它能运行多长时间。后一个问题也称为调度方式。调度方式有两种:不可抢占(或不可剥夺)方式和可抢占(或可剥夺)方式。
不可抢占方式是指一旦某个就绪进程获得CPU后,只要不是进程主动放弃,将会一直运行下去,直到运行结束,期间CPU不可剥夺。可抢占方式是指:当一个进程正在运行时,系统可根据某种原则,剥夺其CPU的使用并分配给其他进程,剥夺原则包括优先权、短进程优先、时间片等。
进程调度算法种类较多,但概括起来最基本的算法主要有静态优先级,动态优先级和时间片轮转等。实际系统中采用的调度算法一般是多种算法结合和改进,Windows系统采用的是“抢占式多任务”就是一种时间片和优先级相结合的调度方式。系统为每个进程分配一定的CPU时间,当程序的运行超过规定时间后,系统就会中断该进程并把CPU的控制权转交给优先级较高的进程,如果无更高级别的进程,则转交给其他相同优先级的进程。
线程
线程是为了在进程内部实现并发性而引入的概念。
进程内部的并发行是指:在同一个进程内部可以同时进行多项工作,而线程就是完成其中某一项工作的单一指令序列。一般情况下,同一进程中的多个线程各自完成不同的工作,比如一个线程负责通过网络收发数据,另一个线程完成所需的计算工作,第三个线程来执行文件输入输出,当其中一个由于某种原因阻塞后,比如通过网络收发数据的线程等待对方发送数据,另外的线程仍然能执行而不会被阻塞。
一个进程内的所有线程使用同一个地址空间,即各线程是在同一地址空间运行的,各线程自己并不独立拥有系统资源,但它可与同属一个进程的其他线程共享进程所拥有的全部资源。解决同意进程的各线程之间如何共享内存、如何通信等问题是多线程编程中的难点。由于线程之间的相互制约,以及程序功能的需求,线程在运行中也会呈现出间断性,因此一个线程在其生命期内有两种存在状态--运行状态和阻塞(挂起)状态。有很多原因可导致线程在这两种状态之间进行切换。线程的状态相互转换常见的如Sleep()函数的调用, Suspend(),等待锁可用,等待I/O操作,Resume(),I/O操作完成,
线程仅仅简单地借用了进程切换的概念,它把进程间的切换变成了同一个进程内的几个函数间的切换。同一个进程中函数间的切换相对于进程切换来说所需的开销要小得多,它只需要保存少数几个寄存器、一个堆栈指针以及程序计数器等少量内容。在进程内创建、终止线程比操作系统创建、终止进程也要快。
有多个线程的程序称为多线程程序。Windows系统支持多线程程序,允许程序中存在多个线程。事实上,任何一个Windows中的应用程序都至少有一个线程,即主线程,其他线程都是主线程的子孙线程。
进程与线程的区别
进程与线程的主要区别在于,多进程中每个进程都有自己的地址空间(address space),而多线程则共享同一进程的地址空间;进程是除CPU以外的资源分配的基本单位,线程主要是执行和调度(CPU运行时间分配)的基本单位。
线程是进程内部的一个执行单元。每一个进程至少有一个主执行线程,它无须用户去主动创建,是系统自动创建的。用户根据需要在应用程序中创建其他线程,多个线程并发地运行于同一个进程中。
一个进程中的所有线程都在该进程的虚拟地址空间中,共同使用该虚拟地址空间中的全局变量和系统资源,所以线程间的通信非常方便。
系统创建好进程后,实际上就启动了执行该进程的主执行线程。在VC++程序中,主线程的启动点是以函数形式(即main或WinMain函数)提供给Windows系统。主线程终止了,进程也将随之终止,而不管其他线程是否执行完毕。
多线程可以实现并行处理,避免了某项任务长时间占用CPU时间。当线程数目多于计算机的CPU数目时,为了运行所有这些线程,操作系统为每个独立线程安排一些CPU时间,操作系统以轮换方式向线程提供时间片,这就造成了一种假象:好像这些线程都在同时运行。
尽管比进程间的切换要好得多,但是线程间的切换仍会消耗很多的CPU资源,在一定程度上,也会降低系统的性能。
Windows多线程编程
多线程给应用开发带来了许多好处,但并非在任何情况下都要使用多线程,一定要根据应用程序的具体情况来综合考虑。一般来说,以下情况下可以考虑使用多线程:
- 应用程序中的各任务相对独立
- 某些任务耗时较多
- 各任务需要有不同的优先级
在VC++程序设计中,有多种方法在程序中实现多线程
- Win32SDK函数
- 使用C/C++运行时库函数
- 使用MFC类库
使用Win32 SDK函数实现多线程
1.创建线程
在程序中创建一个线程需要以下两个步骤:
- 编写线程函数
所有线程必须从一个指定的函数开始执行,该函数就是所谓的线程函数。线程函数必须具有类似下面所示的函数原型:
DWORD ThreadFunc(LPVOID lpvThreadParam);
ThreadFunc是新创建的线程函数的名字,可以由编程者任意指定,但必须符合VC++标识符的命名规范。该函数仅有一个LPVOID类型的参数,LPVOID的类型定义如下:
typedef void * LPVOID;
它既可以是一个DWORD类型的整数,也可以是一个指向一个缓冲区的void类型的指针。函数返回一个DWORD类型的值。
一般来说,C++的类成员函数不能作为线程函数。这是因为在类中定义的成员函数,编译器会给其加上this指针。但如果需要线程函数像类的成员函数那样能访问类的所有成员,可采用两种方法。第一种方法是将该成员函数声明为static类型,但static成员函数只能访问static成员,不能访问类中的非静态成员,解决此问题的一种途径是可以在调用类静态成员函数(线程函数)时,将this指针作为参数传入,并在该线程函数中用强制类型转换将this转换成指向该类的指针,通过该指针访问非静态成员。第二种是不定义类成员函数为线程函数,而将线程函数定义为类的友元函数,这样线程函数也可以有类成员函数同等的权限。
- 创建一个线程
进程的主线程是操作系统在创建进程时自动生成的,但如果要让一个线程创建一个新的线程,则必须调用线程创建函数。Win32 SDK提供的线程创建函数是CreateThread()。
函数原型
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
DWORD dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
函数参数
- lpThreadAttributes:指向一个SECURITY_ATTRIBUTES结构的指针,该结构决定了线程的安全属性,一般置为NULL
- dwStackSize:指定线程的堆栈深度,一般设置为0
- lpStartAddress:线程起始地址,通常为线程函数名
- LPTHREAD_START_ROUTINE类型定义:
typedef unsigned long (__stdcall *LPTHREAD_START_ROUTINE) (void* lpThreadParameter);
- lpParameter: 线程函数的参数
- dwCreationFlags: 控制线程创建的附加标志。该参数为0,则线程在被创建后立即开始执行;如果该参数为CREATE_SUSPENDED, 则创建线程后盖线程处于挂起状态,直至函数ResumeThread被调用
- lpThreadID: 该参数返回所创建线程的ID
返回值
- 该函数在其调用进程的进程空间里创建一个新的线程,并返回已建线程的句柄,如果创建成功则返回线程的句柄,否则返回NULL。
注意:使用同一个线程函数可以创建多个各自独立工作的线程。
编写如下示例程序1.
// TestProject.cpp : 定义控制台应用程序的入口点。
#include "stdafx.h"
#include <stdio.h>
#include <Windows.h>
#define N 100000
int ThreadF0(LPVOID lpParam)
{
long *a = (long*)lpParam;
for (int i = 0; i < N; ++i) {
//InterlockedExchangeAdd(a, 1);
(*a) +=1;
//Sleep(3);
//printf("0: %d
", *a);
}
printf("0: %d
", *a);
return 0;
}
int ThreadF2(LPVOID lpParam)
{
long *b = (long*)lpParam;
for (int i = 0; i < N; ++i) {
(*b) += 1;
//InterlockedExchangeAdd(b, 1);
//Sleep(2);
//printf(" 1: %d
", *b);
}
printf("2: %d
", *b);
return 0;
}
int main(int argc, char *argv[])
{
int t = 0;
HANDLE htd0, htd1, htd2;
DWORD thrdID0, thrdID2;
htd0 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ThreadF0, (void*)&t, 0, &thrdID0);
htd1 = 0;
//htd1 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ThreadF1, (void*)&t, 0, &thrdID1);
htd2 = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ThreadF2, (void*)&t, 0, &thrdID2);
Sleep(1000);
printf("t:%d
", t);
printf("hello, world
");
return 0;
}
输出如下:
0: 100000
2: 120107
t:120107
hello, world
请按任意键继续. . .
需要特别说明的是:
这里的循环次数N不能设置得太小,因为现在CPU运行速度很快,如果设置得太小是无法(理论上也可以看到,只是出现的概率很低)看到示例1中线程切换引起的异常的。
如果没有线程切换,t最终的值应该是200000,但是这里线程0和线程2切换,彼此相互影响了,使得t最后没有达到200000.
另,sleep会引起线程之间的主动切换。所以,系统会每次在运算(加1之前或者加1之后)后,如果你在线程中使用了sleep遇到sleep就切换线程。
所以需要在正在加1的过程中切换线程,才能看到这样不做控制是有问题的。
而要在正在加的过程中切换线程,只能由系统自动切,不能通过主动调用sleep来实现。
在简单的加法操作中,可以使用InterlockedExchangeAdd函数来进行运算,这样就不会怕线程切换了。
复杂的多步操作用锁、临界区等线程同步的操作。
而操作系统的进程(或者线程)间通信,可以用事件对象(Windows系统)结合WaitForSingleObject()函数,socket,信号,管道,消息队列,共享内存等方式
另外两个小的线程例子参见:
此外,Windows操作系统还提供了Sleep()函数
VOID Sleep(DWORD dwMilliseconds);
Sleep()函数是一个Windows API函数,其功能使线程阻塞dwMilliseconds毫秒。使用Sleep()函数需要包含头文件"windows.h"
函数参数:
dwMilliseconds:指定线程阻塞的时间长度,时间的单位是毫秒(ms)。如果参数取值为0,执行该函数也将使线程阻塞转而执行其他同优先级的线程,如果不存在其他同优先级的线程,线程将立刻恢复执行。如果取值为常量INFINITE,则线程将被无限期阻塞。
线程函数的参数传递
由CreateThread函数原型可以看出,创建线程时,可以给线程传递一个void指针类型的参数,该参数为CreateThread()函数的第四个参数。
当需要将一个整型数据作为线程函数的参数传递给线程时,可将该整型数据强制转换为LPVOID类型,作为它的实参传递给线程函数。
当需要向线程传递一个字符串时,则创建线程时的实参传递既可以使用字符数组,也可以使用std::string类。使用字符数组时,实参可直接使用字符数组名或指向字符串的char *类型的指针。使用std::string类型时,可将指向std::string对象的指针强制转换为LPVOID。
如果需要向线程传送多个数值时,由于线程函数的参数只有一个,所以需要先将它们封装在一个结构体变量中,然后将该变量的指针作为参数传递给线程函数。
示例代码见:
https://github.com/xiaoliuzi/netlib_demo/tree/master/learn_multi_thread
参考:
《Windows网络编程基础教程》 杨传栋 张焕远 编著 清华大学出版社