多线程

基本概念

  • 线程是CPU调度和分派的基本单位。
  • 进程
    • 是分配资源的基本单位。
    • 等同于正在运行的程序以及它所管理的资源。

image-20211226150432077

创建线程

  • CreateThread——Windows中创建线程API
  • baginthreadex——底层实现是CreateThread

示例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include<stdio.h>
#include<windows.h>
#include<process.h>


unsigned int __stdcall thread_main_dog(void* cnt)
{
int i = *((int*)cnt);
for (int j = 0; j < i; j++)
{
printf("www\n");
Sleep(1000);
}
return 0;
}
unsigned int __stdcall thread_main_cat(void* cnt)
{
int i = *((int*)cnt);
for (int j = 0; j < i; j++)
{
printf("mmm\n");
Sleep(2000);
}
return 0;
}
unsigned int __stdcall thread_main_bird(void* cnt)
{
int i = *((int*)cnt);
for (int j = 0; j < i; j++)
{
printf("jjj\n");
Sleep(3000);
}
return 0;
}
int main(void)
{
//安全属性——线程堆的大小默认为0——线程函数——线程函数的参数——线程的初识状态0表示立即执行——用来接收线程ID
unsigned int dog_id = 0;
unsigned int cat_id = 0;
unsigned int bird_id = 0;
int count = 10;
_beginthreadex(NULL, 0, thread_main_dog, (void*)&count,0,&dog_id);
_beginthreadex(NULL, 0, thread_main_cat, (void*)&count,0,&cat_id);
_beginthreadex(NULL, 0, thread_main_bird, (void*)&count,0,&bird_id);

printf("%d %d %d\n", dog_id, cat_id, bird_id);


//system("pause");
Sleep(1000000);
return 0;
}

示例2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include<windows.h>
#include<stdio.h>
#include<process.h>

DWORD WINAPI ThreadFun(LPVOID p)
{
int temp = *((int*)p);
printf("我是子线程,PID=%d,temp = %d\n", GetCurrentThreadId(),temp);
return 0;
}

int main(void)
{
printf("我是主线程\n");
HANDLE hThread;
DWORD dwThreadID;
/*
安全属性,一般填NULL
线程栈空间大小,0为默认大小1MB
线程函数地址
传给线程函数的参数
指定额外的标志来控制线程的创建*/
int m = 10;
hThread = CreateThread(NULL,0,ThreadFun,&m,0,&dwThreadID);
Sleep(10000);
//关闭线程
CloseHandle(hThread);
return 0;
}

内核对象

  • 理解内核对象

定义:内核对象通过API来创建,每个内核对象是一个数据结构,它对应一块内存, 由操作系统内核分配,并且只能由操作系统内核访问。在此数据结构中少数成员如安全描述符和使用计数是所有对应都有的,但其他大多数成员都是不用类型的对象特有的。内核对象的数据结构只能由操作系统提供的API访问,应用程序在内存中不能访问。调用创建内核对象的函数后,该函数会返回一个句柄,它标识了所创建的对象。它可以由进程的任何线程使用。

**常见的内核对象:**进程、线程、文件、存取符号对象、事件对象、文件对象、作业对象、互斥对象、管道对象、等待计时器对象、邮件槽对象,信号对象。(创建时会产生内核对象。)

CreateProcess

CreateThread

CreateFile

Job

Mutex

……

内核对象:为了管理线程/文件等资源而由操作系统创建的数据块。

其创建的所有者肯定是操作系统。

带create的都是内核对象的创建。

主线程和子线程的结束时间

main函数返回后,整个进程终止,同时终止其包含的所有线程。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include<iostream>
#include<windows.h>
#include<process.h>

unsigned int __stdcall ThreadFunc(LPVOID p)
{
int cnt = *((int*)p);
for (int i = 0; i < cnt; i++)
{
Sleep(1000);
printf("runing thread\n");
}
return 0;
}

int main(void)
{
printf("main begin\n");
int iPrarm = 5;
unsigned int dwThreadID;
HANDLE hThread = (HANDLE)_beginthreadex(NULL, 0, ThreadFunc, (void*)&iPrarm, 0, &dwThreadID);
//Sleep(20000);
printf("main end\n");
return 0;
}

用WaitForSingleObject来等待一个内核对象变为已通知状态。

为什么使用多线程

  • 避免阻塞

单个进程只有一个主线程,当主线程阻塞的时候,整个进程也就阻塞了,无法再做其它的有一些功能了。

  • 避免CPU空转

应用程序经常会涉及到RPC,数据库访问,磁盘IO等操作,这些操作的速度比CPU慢很多,而在等待这些响应时,CPU却不能去处理新的请求,导致这种单线程的应用程序性能很差。

  • 提升效率

一个进程要独立拥有4GB的虚拟地址空间,而多线程可以共享同一地址空间,线程的切换比进程的切换要快得多。

上下文切换

分时使用CPU需要进行上下文切换。

image-20211228182457603

多线程可以理解成一个轻量级的多进程。

解释:每个进程开始执行 时,需要各个区域读到内存中,下一个进程开始执行时,需要将上一个进程的东西读出来放到硬盘中,再将进程B的各个区域读到内存中,这就是上下文切换。而线程间不用,这些区域资源都是共享的,所以线程间要快于进程间通信。

线程同步

CPU是抢占式的。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include<stdio.h>
#include<windows.h>
#include<process.h>
unsigned WINAPI threadDes(void* arg);
unsigned WINAPI threadInc(void* arg);

#define NUM_THREAD 50
long long num = 0;
//变量放在内存中,计算机的时候从内存中拿出来在CPU中运算,运算完成的变量放回去
/*
在此案例中,上一次的进程对变量进行操作,还没有来得及将计算后的结果放回内存中(替换原来的变量值),下一个线程就开始(切换到了)了(下一个进程拿到的变量值还没有改变),进行相应的操作。
*/
int main(void)
{
HANDLE tHandles[NUM_THREAD];
int i;
printf("size of long long: %d \n",sizeof(long long));
for (i = 0; i < NUM_THREAD; i++)
{
if (i % 2)
{
tHandles[i] = (HANDLE)_beginthreadex(NULL,0,threadInc,NULL,0,NULL);
}
else
{
tHandles[i] = (HANDLE)_beginthreadex(NULL, 0,threadDes,NULL,0,NULL);
}
}

WaitForMultipleObjects(NUM_THREAD,tHandles,TRUE,INFINITE);//等待多个线程结束
printf("%d", num);
return 0;
}

unsigned WINAPI threadInc(void* arg)
{
int i = 0;
for (int i = 0; i < 500000; i++)
{
num += 1;
}
return 0;
}
unsigned WINAPI threadDes(void* arg)
{
int i;
for (i = 0; i < 500000; i++)
{
num -= 1;
}
return 0;
}


解决办法——上一个锁,告诉CPU当前线程正在用这个变量,后面的先别进来。

线程同步之互斥对象

CreateMutex——创建互斥对象。

用互斥体解决上面的这个问题。

避免产生资源竞争。

互斥对象属于内核对象,它能够确保线程拥有对当个资源的互斥访问权。

互斥对象包含一个使用数量,一个线程ID和一个计数器。其线程ID用于标识系统中的哪个线程当前拥有互斥对象,计数器用于指明该线程拥有互斥对象的次数。

创建互斥对象 :调用CreateMutex,调用成功,该函数返回所创建的互斥对象的句柄。

请求互斥对象所有权:调用WaitForSingleObject函数。线程必须主动请求共享独享的所有权才能获得所有权。

释放指定互斥对象的所有权:调用ReleaseMutex函数。线程访问共享资源结束之后,要主动释放对互斥对象的所有权,使该对象处于已通知状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
 #include<stdio.h>
#include<windows.h>
#include<process.h>
unsigned WINAPI threadDes(void* arg);
unsigned WINAPI threadInc(void* arg);

#define NUM_THREAD 50
long long num = 0;
HANDLE hMutex;//定义一个互斥体的句柄
int main(void)
{
HANDLE tHandles[NUM_THREAD];
int i;
hMutex = CreateMutex(NULL, FALSE, NULL);
//临界区——不是一个变量,是一段代码,就是可能会存在前程之间产生资源抢占的代码。
for (i = 0; i < NUM_THREAD; i++)
{
if (i % 2)
{
tHandles[i] = (HANDLE)_beginthreadex(NULL,0,threadInc,NULL,0,NULL);
}
else
{
tHandles[i] = (HANDLE)_beginthreadex(NULL, 0,threadDes,NULL,0,NULL);
}
}

WaitForMultipleObjects(NUM_THREAD,tHandles,TRUE,INFINITE);//等待多个线程结束
CloseHandle(hMutex);
printf("%d", num);
return 0;
}

unsigned WINAPI threadInc(void* arg)
{
int i = 0;
WaitForSingleObject(hMutex, INFINITE);
for (int i = 0; i < 500000; i++)
{
num += 1;
}
//运行完上面的循环,之后realease之后,下面的线程才能执行。
ReleaseMutex(hMutex);
return 0;
}
unsigned WINAPI threadDes(void* arg)
{
int i;
//如果当CPU切换到下一个线程,由于上个线程还没Release,下面的线程只能等待。
WaitForSingleObject(hMutex, INFINITE);
for (i = 0; i < 500000; i++)
{
num -= 1;
}
ReleaseMutex(hMutex);
return 0;
}

示例:

多线程+SOCKET实现群聊服务器

服务端

  1. 每来一个连接,服务端起一个线程(安排一个工人维护)
  2. 将收到的消息转发给所有的客户端
  3. 某个连接断开,需要处理断开的连接

客户端

  1. 连接服务器
  2. 与服务器进行通信

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164

//多线程+socket编程的联合使用
//用互斥体进行线程同步



#include<WinSock2.h>
#include <iostream>
#include<windows.h>
#include<process.h>
#pragma comment(lib,"ws2_32.lib")

#define MAX_CLNT 256
#define MAX_BUF_SIZE
//SOCKET数组
SOCKET clntSocks[MAX_CLNT];
int clntCnt = 0;//当前连接上的socket的数量

HANDLE hMutex;

//发送给所有的客户端
void SendMsg(char* szMsg, int iLen)
{
WaitForSingleObject(hMutex, INFINITE);
for (int i = 0; i < clntCnt; i++)
{
send(clntSocks[i],szMsg,iLen,0);
}
ReleaseMutex(hMutex);
}



//处理客户端链接的函数
unsigned WINAPI HandleCln(void* arg)
{
//接收传过来的参数
SOCKET hClntSock = *((SOCKET*)arg);
int iLen = 0;
char szMsg[MAX_BUF_SIZE] = { 0 };
//进行数据的收发
//while (iLen = recv(hClntSock,szMsg,sizeof(szMsg),0) != 0)
//{
// //将收到的信息发送给当前在线的所有客户端——达到群聊的效果
// SendMsg(szMsg,iLen);
//}

while (1)
{
iLen = recv(hClntSock, szMsg, sizeof(szMsg), 0);
// printf("recv mag = %s iLen = %d \n", szMsg, iLen);
if (iLen > 0)
{
SendMsg(szMsg, iLen);
}
else
{
break;
}
}



WaitForSingleObject(hMutex,INFINITE);
//处理客户端断开链接的情况
for (int j = 0; j < clntCnt; j++)
{
if (hClntSock == clntSocks[j])
{
while (j++ < clntCnt)
{
clntSocks[j] = clntSocks[j + 1];

}
break;
}
}
clntCnt--;
printf("此时连接个数:%d\n", clntCnt);
ReleaseMutex(hMutex);
closesocket(hClntSock);
return 0;
}

int main(void)
{
//初始化套接字库
WORD wVersion;
WSADATA wsaData;
int err;

HANDLE hThread;

wVersion = MAKEWORD(1, 1);
err = WSAStartup(wVersion, &wsaData);//初始化哪个版本的网络库,并且将数据存放到哪里
if (err)
{
return err;
}
if (LOBYTE(wsaData.wVersion) != 1 || HIBYTE(wsaData.wVersion) != 1)//版本检查
{
WSACleanup();//清理套接字库
return -1;
}
//创建一个互斥对象
hMutex = CreateMutex(NULL,FALSE,NULL);


//创建套接字
SOCKET sockServer = socket(AF_INET, SOCK_STREAM, 0); //协议族、协议栈
//准备绑定的信息
SOCKADDR_IN addrServer;
addrServer.sin_addr.S_un.S_addr = htonl(INADDR_ANY);//htonl——
addrServer.sin_family = AF_INET;//协议栈
addrServer.sin_port = htons(6000);//端口-1024以下的端口是系统保留的,htons——大小端转换,x86小端,网络传输-大端
//绑定到本机
if (bind(sockServer, (SOCKADDR*)&addrServer, sizeof(SOCKADDR)) == SOCKET_ERROR)
{
printf("bing error:%d",GetLastError());
return -1;
}

//监听
std::cout << "Server start at port 6000" << std::endl;
if (listen(sockServer, 10) == SOCKET_ERROR)//第二个参数是最大排队连接的个数
{
printf("listen errornum = %d\n",GetLastError());
return -1;
}
SOCKADDR_IN addrClient;
int len = sizeof(SOCKADDR);
printf("start listen\n");
while (1)
{
//此时来的客户端链接
//接收连接请求,返回根据客户端的套接字
SOCKET sockConnect = accept(sockServer, (SOCKADDR*)&addrClient, &len);


//每来一个链接,就起一个线程
//没来一个连接,全局数组+1个成员,最大连接数+1

WaitForSingleObject(hMutex,INFINITE);

clntSocks[clntCnt++] = sockConnect;//当发生客户端链接的时候,这里有可能发生全局变量的竞争,所以加锁
printf("有人下机了,此时连接数目%d\n",clntCnt);
ReleaseMutex(hMutex);

hThread = (HANDLE)_beginthreadex(NULL, 0, HandleCln, (void*)&sockConnect, 0, NULL);

printf("Connect client IP is :%s \n",inet_ntoa(addrClient.sin_addr));
printf("Connect client NUM is :%d \n",clntCnt);
}
//关闭套接字
closesocket(sockServer);
//清理套接字库
WSACleanup();



return 0;
}


客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
//客户端要干什么
/*
* 接收服务端的消息——起一个线程用来接收消息
* 发送消息个服务端——起一个线程用来发送消息
* 退出机制
*/


#include<WinSock2.h>
#include <iostream>
#include<windows.h>
#include<process.h>

#pragma comment(lib,"ws2_32.lib")

#define BUF_SIZE 256
#define NAME_SIZE 20
char szName[NAME_SIZE] = "[DEFAULT]";
char szMsg[BUF_SIZE];

//接收消息
unsigned WINAPI RecvMsg(void* arg)
{
SOCKET hClntSock = *((SOCKET*)arg);
char szNameMsg[NAME_SIZE + BUF_SIZE];
int iLen = 0;
while (1)
{
iLen = recv(hClntSock, szNameMsg, NAME_SIZE + BUF_SIZE - 1, 0);
//服务端断开
if (iLen == -1)
{
return -1;
}
szNameMsg[iLen] = 0;
fputs(szNameMsg,stdout);
}

return 0;
}
//发送消息
unsigned WINAPI SendMsg(void* arg)
{
SOCKET hClntSock = *((SOCKET*)arg);
char szNameMsg[NAME_SIZE + BUF_SIZE];//名字+消息
//循环接收来自于控制台的消息
while (1)
{
fgets(szMsg,BUF_SIZE,stdin);//从控制台中获取消息——会发生阻塞
//退出机制——当收到Q or q 就退出
if (!strcmp(szMsg,"Q\n") || !strcmp(szMsg,"q\n"))
{
closesocket(hClntSock);
exit(0);
}
sprintf(szNameMsg, "%s %s", szName, szMsg);//字符串拼接
send(hClntSock,szNameMsg,strlen(szNameMsg),0);
}

return 0;
}

//带参数的main函数,用命令行启动
int main(int argc,char* argv[])
{
//初始化套接字库
WORD wVersion;
WSADATA wsaData;
int err;
SOCKET hSock;
SOCKADDR_IN servAdr;

HANDLE hSThread;
HANDLE hRThread;

wVersion = MAKEWORD(1, 1);
err = WSAStartup(wVersion, &wsaData);//初始化哪个版本的网络库,并且将数据存放到哪里
if (err)
{
return err;
}
if (LOBYTE(wsaData.wVersion) != 1 || HIBYTE(wsaData.wVersion) != 1)//版本检查
{
WSACleanup();//清理套接字库
return -1;
}
sprintf(szName,"[%s]",argv[1]);

//建立Socket
hSock = socket(PF_INET,SOCK_STREAM,0);
//配置端口和地址
memset(&servAdr,0,sizeof(servAdr));
servAdr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
servAdr.sin_family = AF_INET;
servAdr.sin_port = htons(6000);
//连接服务器
if (connect(hSock, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR)
{
printf("connect error is %d\n",GetLastError());
return -1;
}

//起一个用来收消息

hRThread = (HANDLE)_beginthreadex(NULL,0,RecvMsg,(void*)&hSock,0,NULL);

//起一个线程用来发消息

hSThread = (HANDLE)_beginthreadex(NULL, 0, SendMsg, (void*)&hSock, 0, NULL);

WaitForSingleObject(hSThread,INFINITE);
WaitForSingleObject(hRThread,INFINITE);

//关闭套接字
closesocket(hSock);
WSACleanup();
return 0;
}

线程同步之事件对象

事件对象也属于内核对象,它包含以下三个成员:

  • 使用计数
  • 用于指明该事件是一个自动重置的事件还是一个人工重置的时间的布尔值;
  • 用于指明改时间处于已通知状态还是未通知状态的布尔值。

事件对象有两种类型:人工重置的事件对象和自动重置的事件对象。这两种的区别在于,当人工重置的事件对象得到通知时,等待该事件对象的所有线程均变为可调度线程;而当一个自动重置的事件对象得到通知时,等待该事件对象的线程中只有一个线程变为可调度线程。

1.创建事件对象

调用CreateEvent函数创建或打开一个命名的或匿名的事件对象。

1
2
3
4
5
6
HANDLE CreateEvent(   
LPSECURITY_ATTRIBUTES lpEventAttributes, // 安全属性   
BOOL bManualReset,   // 复位方式  TRUE 必须用ResetEvent手动复原 FALSE 自动还原为无信号状态
BOOL bInitialState,   // 初始状态   TRUE 初始状态为有信号状态 FALSE 无信号状态
LPCTSTR lpName     //对象名称  NULL 无名的事件对象 
);

2.设置事件对象状态

调用SetEvent函数把指定的事件对象设置为有信号状态。

3.重置事件对象状态

调用ResetEvent函数把指定的事件对象设置为无信号状态。

4.请求事件对象

线程通过调用WaitForSingleObject函数请求事件对象。

示例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include<stdio.h>
#include<windows.h>
#include<process.h>
#define STR_LEN 100

static char str[STR_LEN];
static HANDLE hEvent;

unsigned WINAPI NumberOfA(void* arg)
{
int i, cnt = 0;
WaitForSingleObject(hEvent,INFINITE);//没有执行,在本个例子中,Num of others肯定会先进行打印, 因为当前行代码正在等待事件的相应
for ( i = 0; str[i] != '\0'; i++)
{
if (str[i] == 'A')
{
cnt++;
}
}
printf("Num of A: %d\n",cnt);
return 0;
}

unsigned WINAPI NumberOfOthers(void* arg)
{
int i, cnt = 0;
for ( i = 0; str[i]!= '\0'; i++)
{
if (str[i] != 'A')
{
cnt++;
}
}
printf("Num of others: %d\n", cnt-1);//减一是减去/n这个换行符
//
SetEvent(hEvent);
return 0;
}
int main(int argc,char* argv[])
{
HANDLE hThread1, hThread2;
fputs("input string:\n",stdout);
fgets(str,STR_LEN,stdin);
//默认安全符-手动-初识状态为无信号状态
hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
hThread1 = (HANDLE)_beginthreadex(NULL,0,NumberOfA,NULL,0,NULL);
hThread2 = (HANDLE)_beginthreadex(NULL,0,NumberOfOthers,NULL,0,NULL);
WaitForSingleObject(hThread1, INFINITE);
WaitForSingleObject(hThread2, INFINITE);
//直到两个线程执行完毕之后,再把事件设置为无信号状态
ResetEvent(hEvent);
CloseHandle(hEvent);
system("pause");
return 0;
}

示例2:AB同时卖票

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include<stdio.h>
#include<windows.h>
#include<process.h>

int iTickets = 100;
HANDLE g_hEvent;

DWORD WINAPI SellTicketA(void* lpParam)
{
while (1)
{
WaitForSingleObject(g_hEvent,INFINITE);
if (iTickets > 0)
{
Sleep(1);
iTickets--;
printf("A remain %d\n",iTickets);
}
else
{
break;
}
SetEvent(g_hEvent);
}
return 0;
}

DWORD WINAPI SellTicketB(void* lpParam)
{
while (1)
{
WaitForSingleObject(g_hEvent, INFINITE);
if (iTickets > 0)
{
Sleep(1);
iTickets--;
printf("B remain %d\n", iTickets);
}
else
{
break;
}
SetEvent(g_hEvent);
}
return 0;
}

int main(void)
{
HANDLE hThreadA;
HANDLE hThreadB;
hThreadA = CreateThread(NULL,0,SellTicketA,NULL,0,0);
hThreadB = CreateThread(NULL,0,SellTicketB,NULL,0,0);

CloseHandle(hThreadA);
CloseHandle(hThreadB);

g_hEvent = CreateEvent(NULL,FALSE,FALSE,NULL);//自动还原成无信号状态
SetEvent(g_hEvent);
Sleep(4000);
CloseHandle(g_hEvent);
return 0;
}

深入理解Windows内核对象与句柄

内核对象

Windows中每个内核对象都只是一个内存块,它由操作系统内核分配,并只能由操作系统内核进行访问,应用程序不能再内存中定位这些数据结构并直接更改其内容。这个内存块是一个数据结构,其成员维护着与对象相关的信息。少数成员(安全描述符和使用计数)是所有内核对象都有的,但大多数成员都是不同类型对象持有的。

内核对象的使用计数与声明周期

内核对象的所有者是操作系统,而非进程。

就是说,当进程退出,内核对象不一定会销毁。操作系统内核通过内核对象的使用计数,知道当前有多少个进程正在使用一个特定的内核对象。初次创建内核对象,使用计数为1。当另一个进程获得该内核对象的访问权之后,使用计数加1。

如果内核对象的使用计数递减为0,操作系统内核就会销毁该内核对象。

也就是说内核对象在当前进程中创建,但是当前进程退出时,内核对象有可能被另外一个进程访问。这时,进程退出只会减少当前进程对引用的所有内核对象的使用计数,而不会减少其他进程对内核的使用计数(即使该内核对象由当前进程创建)。那么内核对象的使用计数未递减为0,操作系统内核不会销毁该内核对象。

如下图所示image-20211229172046095

  1. 进程1退出,2不退出时。内核对象A,B的引用计数减为0,被操作系统内核销毁,而进程1只减少自身对CD的引用计数,不会影响进程2对C、D的引用计数,此时C、D的引用计数不为0,不会被销毁。
  2. 进程2退出,1不退出时。进程2减少自身对C、D的引用次数,不会影响进程1,故A,B,C,D都不会被销毁
  3. 进程1,2均退出时,只要 ABCD不被其它进程使用,内核推向ABCD的引用计数均递减为0,被内核销毁。
  4. 进程1和2均为退出时,内核对象ABCD的引用计数只要有一个递减为0,那么递减为0的内核对象便被内核销毁。

操作内核对象

Windows提供了一组函数进行操作内核对象。成功调用一个创建内核对象的函数后,会返回一个句柄,它表示了所创建的内核对象,可由进程中的任何线程使用。在32位进程中,句柄是一个32位值,在64位进程中句柄是一个64位的值。我们可使用唯一标识内核对象的句柄,调用内核操作函数对内核对象进行操作。

内核对象与其他类型的对象

Windows进程中除了内核对象外还有其他类型的对象,比如窗口,菜单,字体等,这些属于用户对象和GDI对象。要区分内核对象与非内核对象,最简单的方法就是查看创建这个对象的函数,几乎所有创建内核对象的函数都有一个允许我们指定安全属性的参数。

注意:

  1. 一个对象是不是内核对象,通常可以看创建此对象API的参数中是否需要PSECURITY_ATTRIBUTES 类型的参数。
  2. 内核对象只是一个内存块,这块内存位与操作系统内核的地址空间,内存块中存放一个数据结构 (此数据结构的成员有如:安全描述符、计数器等)。
  3. 每个进程中有一个句柄表,这个句柄表(handle table) 仅供内核对象使用。

image-20211229194401299

4.调用创建内核对象的函数后,其实就是相当于操作系统多了一个内存块,这个内存块就是内核对象。

5.调用API CreateThread的时候,比仅仅是创建了一个内核对象,引用计数+1,(创建线程的这个函数)还打开(访问)了内核对象,引用计数+1,所以引用计数就变为2了。(也就是说实际上创建一个内核对象之后,真是的引用计数其实是2)

6.当调用CloseHandle(hThread)时 ,系统通过hThread计算出此句柄在句柄表中的索引,然后把那一项标注为空闲可用的项,内核对象的引用计数-1,即此时此内核对象的引用计数为1,之后这个线程句柄与创建的内核对象已经没有任何关系了。

就是说你close之后,该句柄已经和内核对象(内存块)没关系了。

只有当内核对象的引用计数为0的时候,内核对象才会被销毁。

但是,我们已经关闭了线程句柄,也就是这个线程句柄已经和这个内核对象已经没有瓜葛了,那么那个内核对象是怎么又可以和此线程联系起来了呢?——通过创建线程时产生的那个线程ID。

1
2
//访问上面已经调用close的线程1的内核对象,线程ID从创建线程1时获取。
headle2 = OpenThread(THREAD_QUERY_INFORMATION, FALSE, threadId);

线程同步之信号量

  • 信号量可以起到类似于限流参观的作用,一个博物馆只能进这么些人同时参观,后来的人需要在后面排队,信号量就是把门的,满了拦着人不让进,有位置就往里面放人。每个人就相当于线程。信号量限制线程。这个限制的条件(例如:几个人能同时从参观。)是由程序员来决定的。信号量限制可活动的线程数。

  • 信号量类似于事件对象,同样有两个状态:

    • 触发状态(有信号状态),表示有可用资源。
    • 未触发状态(无信号状态),表示没有可用资源。
  • 信号量的组成

    • 计数器:该内核对象被使用的次数
    • 最大资源数量: 标识信号量可以控制的最大资源数量(带符号的32位 )
    • 当前资源数量: 标识当前可用资源的数量(带符号的32位)。即表示当前开放的资源个数(注意不是剩下资源的个数), 只有开放的资源才能被线程所申请。但这些开放的资源不一定被线程占 用完。例如:当前开放5个资源,只有3个线程申请,则还有2个资源可被申请,但如果这时总共是7个线程要使用信号量,显然开放的5个资源是不够的。这时还可以再开放两个,直到达到最大资源数量。

    信号量的规则如下

    1. 如果当前资源计数大于0,那么信号量处于触发状态(有信号状态),表示有可用资源。
    2. 如果当前资源技术等于0,那么信号量属于未触发状态(无信号状态),表示没有可用资源。
    3. 系统绝对不会让当前资源计数变为负数。
    4. 当前资源计数绝对不会大于最大资源计数。

image-20211230183831748

信号量与互斥量不用的地方在于,信号量允许多个线程再同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。

信号量对象对线程的同步方式与前面几种方法不同,信号允许多个线程同时使用共享资源。

类比理解: 信号量就是停车场保安,线程就是车辆,例如当前停车场已满(所有资源都被占用),开走一个资源+1(有空车位),进来一个车-1。(资源可以理解为“变量——临界区的内容”)

创建信号量

1
2
3
4
5
6
7
8
9
HANDLE
WINAPI
CreateSemaphoreW(
_In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, // Null 安全属性
_In_ LONG lInitialCount, //初始化时,共有多少个资源是可以用的。 0:未触发状//态(无信号状态),表示没有可用资源

_In_ LONG lMaximumCount, //能够处理的最大的资源数量 3
_In_opt_ LPCWSTR lpName //NULL 信号量的名称
);

增加信号量

1
2
3
4
5
6
WINAPI
ReleaseSemaphore(
_In_ HANDLE hSemaphore, //信号量的句柄
_In_ LONG lReleaseCount, //将lReleaseCount值加到信号量的当前资源计数上面 0-> 1
_Out_opt_ LPLONG lpPreviousCount //当前资源计数的原始值
);

关闭句柄

1
2
3
CloseHandle(
_In_ _Post_ptr_invalid_ HANDLE hObject
);

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include<stdio.h>
#include<process.h>
#include<windows.h>

static HANDLE semOne;
static HANDLE semTwo;
static int num;

unsigned WINAPI Read(void* arg)
{
for (int i = 0;i<5;i++)
{
fputs("Input num:\n",stdout);
printf("begin read\n");
WaitForSingleObject(semTwo,INFINITE);//等待内核对象semTwo的信号,如果有信号,就继续执行,如果没信号就等待。
printf("beginning read\n");
scanf("%d",&num);
ReleaseSemaphore(semOne,1,NULL);//释放and给semOne一个信号
}
return 0;
}
unsigned WINAPI Accu(void* arg)
{
int sum = 0;
for (int i = 0; i < 5; i++)
{
printf("begin Accu\n");
WaitForSingleObject(semOne,INFINITE);//等待内核对象semoOne的信号,无信号,等待ing
printf("beginning Accu\n");
sum += num;
printf("sum = %d\n",sum);
ReleaseSemaphore(semTwo,1,NULL);
}
printf("Result: %d\n",sum);
return 0;
}



int main(int argc,char* argv[])
{
HANDLE hThread1, hThread2;
semOne = CreateSemaphore(NULL, 0, 1, NULL);//初始化时没有可用资源,无信号状态最大信号量是1
semTwo = CreateSemaphore(NULL, 1, 1, NULL);//有可用资源,有信号状态
hThread1 = (HANDLE)_beginthreadex(NULL,0,Read,NULL,0,NULL);
hThread2 = (HANDLE)_beginthreadex(NULL,0,Accu,NULL,0,NULL);

CloseHandle(semOne);
CloseHandle(semTwo);
system("pause");
return 0;
}

ReleaseSemaphore当等待函数返回时,它会将信号量的计数减一。当线程完成使用资源时,它调用 ReleaseSemaphore 将信号量的计数增加一。(链接

线程同步之代码段

关键代码段,也称为临界区,工作在用户方式下。它是指一个小代码段,在代码块能够执行前,它必须独占对某些资源的访问权。通常把多线程中访问同一种资源的那部分代码当做关键代码块。

初始化关键代码段

调用InitializeCriticalSection函数初始化一个关键代码段。

1
2
3
InitializeCriticalSection(
_Out_ LPCRITICAL_SECTION lpCriticalSection
);

该函数只有一个指向CRITICAL_SECTION结构体的指针。

进入关键代码段

1
2
3
4
5
VOID
WINAPI
EnterCriticalSection(
_Inout_ LPCRITICAL_SECTION lpCriticalSection
);

调用此函数之前,已获得指定的临界区对象的所有权,该函数等待指定的临界区对象的所有权,如果该所有权赋予了调用线程,则该函数就返回;否则该函数会一直等待,从而导致线程等待。

退出关键代码段

1
2
3
4
VOID
WINAPI
LeaveCriticalSection(
_Inout_ LPCRITICAL_SECTION lpCriticalSection);

线程使用完临界区所保护的资源之后,需要调用此函数,释放指定的临界对象的所有权

之后,其它想要获得该临界区所有权的线程就可以获得该所有权,从而进入关键代码段,访问保护的资源

删除临界区

1
2
3
4
5
6
WINBASEAPI
VOID
WINAPI
DeleteCriticalSection(
_Inout_ LPCRITICAL_SECTION lpCriticalSection
);

当临界区不再需要时,可以调用此函数释放掉该对象,该函数将释放一个没有任何线程所拥有的临界区对象的所有资源。

当一个代码段调用LeaveCriticalSection之前,其他代码段是无法操作这块代码的。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
//程序启动,两个线程开启,只有当一个代码块中调用LeaveCriticalSection另一个代码块才能操作临界区对象,这就会使操作这个临界区对象的只有一个。

#include<stdio.h>
#include<process.h>
#include<windows.h>

int iTickets = 100;
CRITICAL_SECTION g_cs;

//A B 两个买票窗口

DWORD WINAPI SellTicketA(void* lpParrm)
{
while (1)
{
EnterCriticalSection(&g_cs);//进入临界区(关键代码段)
if (iTickets > 0)
{
Sleep(1);
iTickets--;
printf("A remain %d\n",iTickets);
LeaveCriticalSection(&g_cs);//进入临界区
}
else
{
LeaveCriticalSection(&g_cs);
break;
}
}
return 0;
}

DWORD WINAPI SellTicketB(void* lpParrm)
{
while (1)
{
EnterCriticalSection(&g_cs);
if (iTickets > 0)
{
Sleep(1);
iTickets--;
printf("B remain %d\n",iTickets);
LeaveCriticalSection(&g_cs);
}
else
{
LeaveCriticalSection(&g_cs);
break;
}
}

return 0;
}


int main(void)
{
HANDLE hThreadA, hThreadB;
hThreadA = CreateThread(NULL,0,SellTicketA,NULL,0,NULL);
hThreadB = CreateThread(NULL,0,SellTicketB,NULL,0,NULL);

CloseHandle(hThreadA);
CloseHandle(hThreadB);
//初始化关键代码块
InitializeCriticalSection(&g_cs);
Sleep(4000);
DeleteCriticalSection(&g_cs);//删除临界区(关键代码段)

system("pause");
return 0;
}

线程同步之线程死锁

死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。

Enter和Leave要成对出现。

只有用户态下,发生线程死锁,内核对象是没有死锁的。

**示例:**死锁情况。

开启程序,开启线程,线程1进入A代码段,睡1s,切换线程2,进入B代码段,睡1s,此时两个线程相互等待,发生死锁。(这里用切换,是因为本质上,同一时间CPU只能执行一个任务,显式的多线程是CPU在多个任务之间来回切换,因为速度及快,所以我们理解为多个任务同时进行,也就是多线程。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <stdio.h>
#include <windows.h>
#include <process.h>

int iTickets = 5000;
CRITICAL_SECTION g_csA;
CRITICAL_SECTION g_csB;

// A窗口 B窗口

DWORD WINAPI SellTicketA(void* lpParam)
{
while (1)
{
EnterCriticalSection(&g_csA);//进入临界区A
//Sleep(1);
EnterCriticalSection(&g_csB);//进入临界区B
if (iTickets > 0)
{
Sleep(1);
iTickets--;
printf("A remain %d\n", iTickets);
LeaveCriticalSection(&g_csB);//离开临界区B
LeaveCriticalSection(&g_csA);//离开临界区A
}
else
{
LeaveCriticalSection(&g_csB);//离开临界区B
LeaveCriticalSection(&g_csA);//离开临界区A
break;
}
}
return 0;
}

DWORD WINAPI SellTicketB(void* lpParam)
{
while (1)
{
EnterCriticalSection(&g_csB);//进入临界区B
Sleep(1);
EnterCriticalSection(&g_csA);//进入临界区A
if (iTickets > 0)
{
Sleep(1);
iTickets--;
printf("B remain %d\n", iTickets);
LeaveCriticalSection(&g_csA);//离开临界区A
LeaveCriticalSection(&g_csB);//离开临界区B
}
else
{
LeaveCriticalSection(&g_csA);//离开临界区A
LeaveCriticalSection(&g_csB);//离开临界区B
break;
}
}
return 0;
}


int main()
{
HANDLE hThreadA, hThreadB;
hThreadA = CreateThread(NULL, 0, SellTicketA, NULL, 0, NULL); //2
hThreadB = CreateThread(NULL, 0, SellTicketB, NULL, 0, NULL); //2
CloseHandle(hThreadA); //1
CloseHandle(hThreadB); //1
InitializeCriticalSection(&g_csA); //初始化关键代码段A
InitializeCriticalSection(&g_csB); //初始化关键代码段B
Sleep(40000);
DeleteCriticalSection(&g_csA);//删除临界区
DeleteCriticalSection(&g_csB);//删除临界区
system("pause");

return 0;
}

线程同步比较

Windows线程同步的方式主要有四种:

  • 互斥对象
  • 事件对象
  • 信号量
  • 关键代码块

区别:

  • 互斥对象和事件对象以及信号量都属于内核对象,利用内核对象进行线程同步时,速度较慢,但利用互斥对象和事件对象这样的内核对象,可以在多个进程中个各个线程间进行同步
  • 关键代码块工作在用户方式下,同步速度较快,但在使用关键底代码块时,很容易进入死锁状态,因为在等待进入关键代码段时无法设定超时值。

用户级别的:关键代码段,只能本进程中。

内核级别的:互斥量/事件/信号量,可以跨进程。

通常,在编写多线程程序并需要实现线程同步时,首先关键代码块,因为它的使用比较简单。

如果是在MFC中使用的话,可以在类的构造函数initxxx中调用InitializeCriticalSection函数,在该类的析构函数中调用DeleteCriticalSection函数,在所需要保护的代码前调通用EnterCriticalSection函数,在访问完所需保护的资源后,调用LeaveCriticalSection函数。

需要注意的是: A在调用了EnterCriticalSection后,要相应的调用LeaveCriticalSection函数,否则其他等待该临界区对象所有权的线程将无法执行。

B如果访问关键代码段时,使用了多个临界区对象,就要注意防止线程死锁的发生。

另外,如果需要在多个进程间的各个线程间实现同步的话,可以使用互斥对象和事件对象或者信号量。

总结图: image-20220101110746152

什么是线程安全? 假如你的代码在多线程执行和单线程执行永远是完全一样的结果,那么你的代码是线程安全的。