在Linux网络编程中,我们应该见过很多网络框架或者server,有多进程的处理方式,也有多线程处理方式,孰好孰坏并没有可比性,首先选择多进程还是多线程我们需要考虑业务场景,其次结合当前部署环境,是云原生还是传统的IDC等,最后考虑可维护性,其具体的对比在第三部分具体会展开说。
第一部分:多进程
1、创建一个进程
#include
pid_t fork(void);
// 返回值:子进程返回0,父进程返回子进程的pid,出错返回-1。
上面是一个创建进程的函数,那执行当前函数内核会做哪些事情呢?
(1)如果需要创建进程需要调用fork
,进程调用fork,当控制转移到内核中的fork代码;
(2)内核做分配新的内存块和内核数据给子进程;
(3)内核将父进程部分数据结构内容拷贝进子进程,有一部分使用写时复制(copy on write)和父进程共享;
(4)添加子进程到系统进程列表中,同时父进程打开的文件描述符默认在子进程也会打开,且描述符引用计数加1;
(5)fork
返回,内核调度器开始调度,因此fork
之后,变成两个执行流;
2、进程的生成周期
进程创建子进程,当子进程结束以后会出现两种情况。
(1)如果父进程还在,子进程退出到父进程读取状态之前,这段时间为僵尸态,之后父进程可以调用以下函数等待:
#include
#include
pid_t wait(int *stat_loc);
pid_t waitpid(pid_t pid, int *stat_loc, int options);
// 代码样例
...
pid_t pid;
int stat;
while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) { // 非阻塞等待
...
}
...
(2)如果父进程不在,此时子进程会被init进程接管,并等待结束,如果此时子进程一直不退出,就会一直占用内核资源;
3、进程间通讯
在多进程编程模式中,各个进程不是孤立的,需要处理进程间通讯(IPC),如果您已经有所了解可以一起温故。
(1)管道
管道通讯方式在前面已经讲过,通过pipe
系统函数创建fd[0]和fd[1],其中两个句柄就可以提供给父进程和子进程写入或者读出数据。
(2)信号量
信号量是为了解决访问临界区提供的一种特殊变量,支持两种操作:等待和信号,也就是对应P(进入临界区),V(退出临界区);
假设现在有信号量SV,其执行:
- P(SV),如果
SV > 0
,SV将减1;如果SV == 0
,挂起的当前进程; - V(SV),如果有等待SV的进程则唤醒,如果没有则SV将加1;
Linux系统API如下:
#include
int semget(key_t key, int nums, int sem_flags);
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
int semctl(int sem_id, int sem_num, int command, ...);
semget
创建信号量,semop
操作信号量,对应PV操作,semctl
允许对信号量直接控制,为了方便大家理解,在此给一段代码。
...
// op == -1:执行P操作,op == 1:执行V操作
void pv(int sem_id, int op) {
struct sembuf sem;
sem.sem_num = 0;
sem.sem_op = op;
sem,sem_flg = SEM_UNDO;
semop(sem_id, &sem, 1);
}
int main(...) {
int sem_id = semget(IPC_PRIVATE, 1, 0666);
...
pid_t pid = fork();
if (id == 0) {
...
pv(sem_id, -1); // 执行P操作
...
pv(sem_id, 1); // 执行V操作
...
} else {
...
pv(sem_id, -1);
...
pv(sem_id, 1);
...
}
}
(3)共享内存
共享内存是在有些场景下,父进程和子进程需要读写大块的数据,因此Linux系统提供了shmget
,shmat
,shmdt
,shmctl
四个系统调用。
#include
int shmget(key_t key, size_t size, int shmflg);
void* shmat(int shm_id, const void *shm_addr, int shmflg);
int shmdt(const void* shm_addr);
int shmctl(int shm_id, int command, struct shmid_ds* buf);
int shm_open(const char * name, int oflag, mode_t mode);
int shm_unlink(const char * name);
shmget
创建共享内存或者获取已存在的共享内存,key
标识全局唯一共享内存,size
为设置共享内存大小,shmflg
设置的一些宏;
shmat
共享内存被创建以后,不能直接访问,需要关联到进程的地址空间中,可以设置shm_addr = NULL
由操作系统选择;
shm_open
和open
调用类似,是POSIX方法,创建一个共享内存对象,返回句柄与mmap调用;
shm_unlink
删除共享内存标记;
为了方便大家理解,在此给一段代码:
...
shmfd = shm_open("xxxx", O_CREAT | O_RDWR, 0666);
share_mem = (char *)mmap(NULL, BUFFER_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shmfd, 0);
...
注意:共享内存需要考虑多写多读的问题,如果多个进程写,需要加锁处理。
(4)消息队列
#include
int msgget(key_t key, int msgflg);
int msgsnd(int msgid, const void * msg_ptr, size_t msg_size, int msgflg);
int msgrcv(int msgid, void * msg_ptr, size_t msg_sz, long int msgtype, int msgflg);
int msgctl(int msgid, int command, struct msgid_ds * buf);
msgget
创建消息队列,key
标识全局唯一,msgflg
和其他IPC的参数类似;
msgsnd
和msgrcv
是发送和写入消息类型的数据;
为了方便大家理解,在此给一段代码:
...
struct msg_buf
{
long int msg_type;
char text[BUFSIZ];
};
int main(int argc, char **argv)
{
int msgid = -1;
struct msg_buf data;
long int msgtype = 0;
// 建立消息队列
msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
...
// 从队列中获取消息
while (1)
{
if (msgrcv(msgid, (void *)&data, BUFSIZ, msgtype, 0) == -1)
{
// ...
}
// 遇到end结束
if (strncmp(data.text, "end", 3) == 0)
{
break;
}
}
// 删除消息队列
if (msgctl(msgid, IPC_RMID, 0) == -1)
{
...
}
...
}
(5)UNIX域
除了以上的通用的IPC,socket的UNIX域也可以作为进程间通讯,比如使用socket(AF_UNIX, SOCK_STREAM, 0)
,或socketpair
系统调用,或父进程创建一个127.0.0.1
环回接口socket server,子进程通过socket client访问。
4、如何在网络编程中使用多进程
在多进程的网络编程中,实现方式有很多,但是总体还是围绕两条线,其一如何将新建连接分发给子进程,其二如何将数据/信号传给子进程,并监控子进程,下图是其实现方式之一(由于实现细节很多,后续会将实现代码开源到github):
多进程
(1)首先为了性能考虑,进程池是必须的,通过线程池不需要频繁创建和销毁进程;
(2)其次主进程accept
对应的新连接,考虑各个进程之间负载均衡,将新连接通过随机算法分发给子进程;
(3)分发方式可以通过管道,共享内存,消息队列等方式告知子进程,也可以传递数据信息;
(4)子进程收到新连接的句柄,就可以通过内部的epoll
监听IO事件,从而完成send
和recv
;
第二部分:多线程
1、概述
在Linux中,线程是轻量级进程,运行在内核空间,由内核调度,最开始的线程库是linuxThreads
,但是linuxThreads
不符合POSIX标准,后来出现了NGPT和NPTL,其采用的线程模型不一样,所以性能有差异,性能由快到慢是:NPTL > NGPT > linuxThreads
。
其中线程的模型分为三种:
- 多对一(M:1)的用户级线程模型;
- 一对一(1:1)的内核级线程模型:如
linuxThreads
和NPTL
; - 多对多(M:N)的两极线程模型:如NGPT;
现在Linux的2.6内核版本开始,默认使用NPTL线程库(1:1的线程模型),对比linuxThreads
有如下优势:
- 内核线程不再是一个进程,因此避免用进程模拟线程导致的语义问题;
- 摒弃了管理线程,终止线程和回收线程等工作由内核完成;
- 一个进程中的线程可以运行在不同的CPU上,可以充分利用多处理器系统;
- 线程的同步由内核完成,隶属于不同的进程的线程之间也可以共享互斥锁,因此可以实现跨进程的线程同步;
2、线程API
#include
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
void pthread_exit(void *retval);
int pthread_join(pthread_t thread, void **retval);
int pthread_cancel(pthread_t thread);
int pthread_detach(pthread_t thread);
pthread_t pthread_self();
(1)pthread_create
创建线程,thread
表示线程ID,attr
表示设置线程属性,另外传递线程处理函数start_routine
和参数arg
;
(2)pthread_exit
线程退出,可以在start_routine
执行完成以后调用;
(3)pthread_join
是等待线程结束,调用成功返回0,否则返回错误;
(4)pthread_cancel
异常终止一个线程;
(5)pthread_detach
把指定的线程转变为脱离状态,线程有两种属性,一种是joinable,一种是detached,当一个joinable线程终止时,它的线程ID和退出状态将留存到另一个线程对它调用pthread_join,调用前线程的资源不会释放,而脱离detached线程终止时,资源会立刻释放;
(6)pthread_self
获取当前线程ID;
为了方便大家理解,在此给一段代码(使用c++11语法,底层是以上API的封装):
#include
#include
#include
void func(void *arg)
{
std::cout