C语言实现多线程网络通讯

前言

前面我们学习了如何通过C语言写一个网络通信,但是我们发现这样的网络通讯只能一个人去连接这个server,无法达到多个用户来使用,于是我们这里采用多线程去实现这个需求。

多线程

首先复习一下什么是多线程

什么是多线程?

进程是正在运行的程序及其占用的资源,而线程是进程的一条执行路径。所有的线程都是在同一进程空间运行,这也意味着多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。 一个进程可以有很多线程,每条线程并行执行不同的任务。进程可以比作父母给儿女买了一个房子,并对着自己现住的房子一比一的装修好,而线程则是在原有的房子中在开辟出一个新的房间。

多线程和多进程的区别

  • 线程是进程划分成的更⼩的运⾏单位,⼀个进程在其执⾏的过程中可以产⽣多个线程
  • 线程和进程最⼤的不同在于基本上各进程是独⽴的,⽽各线程则不⼀定,因为同⼀进程中的线程极有可能会相互影响
  • 线程执⾏开销⼩,但不利于资源的管理和保护;⽽进程正相反。
  • 进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位,实现了操作系统的并发
  • 线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发;
  • 一个程序至少有一个进程,一个进程至少有一个线程,线程依赖于进程而存在;
  • 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存

消耗资源

从内核的观点看,进程的目的就是担当分配系统资源(CPU时间、内存等)的基本单位。线程是进程的一个执行流,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。据统计,总的说来,一个进程的开销大约是一个线程开销的30倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。

通讯方式

进程之间传递数据只能是通过通讯的方式,即费时又不方便。线程时间数据大部分共享(线程函数内部不共享),快捷方便。但是数据同步需要锁对于static变量尤其注意

多线程和多进程的优缺点

多进程优点:

1、每个进程相互独立,不影响主程序的稳定性,子程序崩溃没关系;

2、通过增加CPU,就可以扩充性能;

3、可以尽量减少线程加锁/解锁的影响,极大提高性能,就算是线程运行的模块算法效率低也没关系;

4、每个子进程都有2GB地址空间和相关资源,总体能够达到的性能上限非常大

多进程缺点:

1、逻辑控制复杂,需要和主程序交互;

2、需要跨进程边界,如果有大数据量传送,就不太好,适合小数据量传送、密集运算。

3、多进程调度开销比较大;

多线程优点:

1、无需跨进程边界

2、程序逻辑和控制方式简答;

3、所有线程可以直接共享内存和变量等;

4、线程方式消耗的总资源比进程方式好

多线程缺点:

1、每个线程与主程序公用地址空间,受限于2GB地址空间;

2、线程之间的同步和加锁控制比较麻烦;

3、一个线程的崩溃可能影响到整个程序的稳定性;

4、到达一定的线程数程度后,即使再增加CPU也无法提高性能,例如Windows Server 2003,大约是1500个左右的线程数就快到极限了(线程堆栈设定为1M),如果设定线程堆栈为2M,还达不到1500个线程总数;

5、线程能够提高的总性能有限,而且流程多了之后,线程本身的调度也是一个麻烦事儿,需要消耗较多的CPU。

最好是多进程和多线程结合,即根据实际的需要,每个CPU开启一个子进程,这个子进程开启多线程可以为若干同类型的数据进行处理。当然你也可以利用多线程+多CPU+轮询方式来解决问题;方法和手段的多样的,关键是自己看起来实现方便又能够满足要求,代价也合适。

应用场景

多进程的应用场景:

nginx主流的工作模式是多进程(也支持多线程)

几乎所有的web server服务器都有多进程,至少有一个守护进程配合一个worker进程,例如apached,httpd等以d结尾的进程,包括init.d本身就是0级进程,所有你认知的进程都是他的进程

chrome浏览器也是多进程方式(原因:1、可能存在一些网页不符合编程规范,容易崩溃,采用多进程,一个网页崩溃也不会影响其他网页,而多线程会。2、网页之间互相隔离,保证安全,不必担心某个网页的恶意代码获取存放在网页中的敏感信息。)

redis也可以归类到“多进程单线程”模型(平时工作是单个进程,涉及耗时操作如持久化或aof重写是会用到多个线程)

多线程应用场景

线程间有数据共享,并且数据需要修改时(不同任务间需要大量共享数据或频繁通信时)。

提供非均质的服务(有优先级任务处理)事件响应应有优先级

单任务并行计算,在非CPU Bound的场景下提高响应优先级,降低延时

与人有IO交互的应用,良好的用户体验(键盘鼠标的输入,立即响应)

案例:桌面软件,响应用户输入的是一个线程,后台程序处理是另一个线程

怎么选:

1)需要频繁创建销毁的优先用线程
这种原则最常见的应用就是Web服务器了,来一个连接建立一个线程,断了就销毁线程,要是用进程,创建和销毁的代价是很难承受的

2)需要进行大量计算的优先使用线程
所谓大量计算,当然就是要耗费很多CPU,切换频繁了,这种情况下线程是最合适的。
这种原则最常见的是图像处理、算法处理。

3)强相关的处理用线程,弱相关的处理用进程

什么叫强相关、弱相关?理论上很难定义,给个简单的例子就明白了。
一般的Server需要完成如下任务:消息收发、消息处理。“消息收发”和“消息处理”就是弱相关的任务,而“消息处理”里面可能又分为“消息解码”、“业务处理”,这两个任务相对来说相关性就要强多了。因此“消息收发”和“消息处理”可以分进程设计,“消息解码”、“业务处理”可以分线程设计。当然这种划分方式不是一成不变的,也可以根据实际情况进行调整。

4)可能要扩展到多机分布的用进程,多核分布的用线程

5)都满足需求的情况下,用你最熟悉、最拿手的方式

至于“数据共享、同步”、“编程、调试”、“可靠性”这几个维度的所谓的“复杂、简单”应该怎么取舍,我只能说:没有明确的选择方法。但我可以告诉你一个选择原则:如果多进程和多线程都能够满足要求,那么选择你最熟悉、最拿手的那个。

需要提醒的是:虽然我给了这么多的选择原则,但实际应用中基本上都是“进程+线程”的结合方式,千万不要真的陷入一种非此即彼的误区。

多线程服务器程序流程

1
2
3
4
5
6
7
8
9
10
11
graph TD
A(开始) --> B[创建socket]
B -->C[绑定地址]
C -->D[监听套接字]
D -->E[接收客户端连接]
E --> Q{是否超过最大连接个数}
Q --> |不超过| Z[创建子线程]
Q --> |超过| 不执行
Z -->a

a(子线程开始) --> b[关闭监听套接字] -->c[处理客户端请求] -->d(子线程退出)

由于客户端并不需要多线程执行,于是我们只需要修改服务端的程序就行

下面是修改后的代码,我用了互斥锁来保证通讯的安全

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 <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <sys/socket.h>

#define BUF_SIZE 128
#define MAX_CLNT 256

void * handle_clnt(void * arg); // 处理客户端请求的函数

int clnt_cnt = 0; // 客户端连接数
int clnt_socks[MAX_CLNT]; // 客户端socket描述符数组
pthread_mutex_t mutx; // 互斥锁

int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t clnt_adr_sz;
pthread_t t_id;
if(argc != 2 )
{
printf("Usage: %s <port>",argv[0]);
exit(1);
}
pthread_mutex_init(&mutx, NULL);//初始化锁创建互斥锁
serv_sock = socket(PF_INET, SOCK_STREAM, 0); // 创建套接字
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1])); // 绑定端口号
if (bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
perror("bind() error");

if (listen(serv_sock, 5) == -1) // 监听连接请求
perror("listen() error");

while (1) {
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz); // 接收客户端请求
pthread_mutex_lock(&mutx); // 互斥锁保护客户端socket数组
clnt_socks[clnt_cnt++] = clnt_sock;
pthread_mutex_unlock(&mutx);
pthread_create(&t_id, NULL, handle_clnt, (void*)&clnt_sock); // 创建新线程处理客户端请求
pthread_detach(t_id); //从状态上实现线程分离,注意不是指该线程独自占用地址空间。
printf("accept c=%d\nclient IP:(%s)connected;client port:(%d)connected\n",
clnt_sock,inet_ntoa(clnt_adr.sin_addr),ntohs(clnt_adr.sin_port));
}
close(serv_sock);
return 0;
}

void * handle_clnt(void * arg) {
int clnt_sock = *((int*)arg);
int str_len = 0;
char msg[BUF_SIZE];
char *r_msg;
while ((str_len = read(clnt_sock, msg, sizeof(msg))) != 0) { // 接收客户端数据
r_msg = "ok,server had receved";
write(clnt_sock, r_msg, strlen(r_msg)); // 发送数据回客户端
memset(msg, 0, sizeof(msg)); // 清空缓冲区
}
pthread_mutex_lock(&mutx);
for (int i = 0; i < clnt_cnt; i++) { // 从客户端socket数组中删除已关闭的客户端
if (clnt_sock == clnt_socks[i]) {
while (i++ < clnt_cnt - 1)
clnt_socks[i] = clnt_socks[i + 1];
break;
}
}
clnt_cnt--;
pthread_mutex_unlock(&mutx);
close(clnt_sock); // 关闭客户端socket
return NULL;
}