前言
在学习操作系统时,我们知道现代计算机往往都是多进程多线程的,多进程和多线程技术能大大提高了CPU的利用率,因此在web服务器的设计中,不可避免地要涉及到多进程多线程技术。
这一章将简要讲解web服务器中的多进程编程,本文不会很详细,也不会在原理性的知识上多费笔墨。如果读者有什么不理解的地方,建议学习一下操作系统的基础知识。
fork系统调用
#include<sys/types.h>
#include<unistd.h>
pid_t fork(void);
- 作用:复制当前进程,在内核进程表创建一个新的表项。新表项许多属性与原进程相同,比如堆指针,栈指针和标志寄存器的值。新进程的PPID为原进程的PID,信号位图被清除(原进程的信号处理函数对新进程不起作用;
- 参数:无
- 返回值(两次):一般根据fork返回值判断正在执行这段代码的是新进程还是原进程
- 父进程中:返回子进程的PID
- 子进程中:返回0
fork的一些注意事项:
- 子进程的代码与父进程完全相同
- 子进程采用写时复制的方式复制父进程的数据(堆数据,栈数据和静态数据)
- 父进程打开的文件描述符在子进程中同样打开,每fork一次文件描述符的全局引用+1
exec系统调用
上面的fork是复制出一个新进程,类似于ctrl + c 和 ctrl + v,而exec系统调用则类似于 ctrl + x, ctrl + v。
#include<unistd.h>
extern char** environ;//设置新程序的环境变量
int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ..., char* const envp[]);
int execv(const char* path, const char* argv[]);
int execvp(const char* file, const char* arg[]);
int execve(const char* path, const char* arg[], char* const envp[]);
- 作用:将当前进程替换成另一个进程并执行
- 参数:
- path: 指定可执行文件的完整路径
- file:接收文件名,该文件的具体位置在环境遍历path中搜寻
- arg:接收可变参数(被传递给新程序的main)
- argv:接收参数数组(被传递给新程序的main)
- envp[ ]:设置新程序的环境变量,若未设置则环境变量由environ指定
- 返回值:一般不返回(这是因为当exec成功执行后,原程序的代码不会执行,返回值也就没用了)
- 失败:-1
PS:exec不会关闭原程序打开的文件描述符
僵尸进程
多进程中父进程一般需要跟踪子进程的退出状态,所以子进程退出时内核一般不会立刻释放其资源。这会出现以下两种情况:
- 子进程运行结束了,父进程还未读取其状态
- 父进程异常终止了,子进程继续运行直至结束,守护进程还未释放其资源
- 这时子进程被称为孤儿进程,孤儿进程会被守护进程init(PID为1)所收养,即其PPID被设为1
- 守护进程init会等待孤儿进程结束并释放其资源
处于以上两种状态的僵尸进程,僵尸进程会占据内核的资源却不行使任何功能,所以我们要避免僵尸进程。方法是wait调用释放僵尸进程.
#include<sys/type.h>
#include<sys/wait.h>
pid_t wait(int* stat_loc);
pid_t waitpid(pid_t pid, int* stat_loc, int options);
- 作用:等待子进程运行结束释放其资源
- 参数
- stat_loc(两个函数调用相同):指向一块内存,该内存存储子进程的退出信息
- pid : 要求释放的子进程
- options : 控制waitpid的行为,常用参数为WNOHANG(设置函数为非阻塞)
- 返回值:
- wait
- 成功:返回结束运行的子进程的PID
- 失败:-1
- waitpid(非阻塞):
- 成功:
- pid指定的子进程还没结束或意外终止:0
- pid指定的子进程正常退出:该子进程的PID
- 失败:-1
很显然这里有一个问题,当使用waitpid函数调用时是非阻塞的,既然是非阻塞的我们就得在得知子进程结束之后再调用它才合理,那么如何得知子进程结束了呢:
答案是:使用SIGCHLD信号,该信号由子进程结束时给父进程发送,父进程在收到这个信号后即可在信号处理函数中调用非阻塞waitpid
static void handle_child(int sig) {
pid_t pid;
int stat;
while ((pid == waitpid(-1 , &stat, WNOHANG)) > 0 ) {
/*处理结束的子进程*/
}
}
管道
通过以上三节,我们学会了创建子进程和新进程,并学会了释放僵尸进程。接下来我们学习如何在进程之间通信,其中最简单的通信方式即管道。
在C++ Webserver从零开始:基础知识(一)——Linux网络编程基础API-CSDN博客中我们介绍了管道使用的API,这里不再赘述,就简要介绍进程之间管道使用的注意事项
我们知道管道由两个文件描述符组成,分别为fd[0]和fd[1]。在fork后两个文件描述符都是打开状态,而又因一个pipe管道只能实现一个方向的数据传输,因此父进程和子进程必须一个关掉fd[0],一个关掉fd[1]。
当然,如果想要实现双向的传输,则需要创建两个管道pipe,在通信中我们称其为全双工管道。
实现全双工管道还可以使用socketpair系统调用。
管道通信的弊端:
管道通信只能是两个关联进程(如父进程和子进程)之间进行通信,而要多个不相关的进程之间通信,则需要使用命名管道FIFO,命名管道我们可以用Linux进行一个简单的测试。
我们使用mkfifo命令创建一个管道
往这个管道中写一个hello
可以看到命令在这里阻塞了,这是因为将数据写入管道之后,另一端必须读取它。这里我再开一个Linux终端,对这个myfifo进行cat得到hello,完成了数据在管道之间传输,而写端的阻塞也被唤醒了。
这个操作反过来也成立,可以先cat监视myfifo管道,当有数据往里写的时候,就会立刻收到数据。管道的数据其实是存储在内核的缓冲区,读取都是去内核读取,遵循先进先出原则,大家可以试试多建几个终端往管道内写,在建几个读,看看会发生什么。
信号量
信号量原语
在学习操作系统的时候相信大家都了解了信号量,这里简单介绍一下。
当多个程序需要访问系统上的某一个资源时(通常为很短的一段代码,称为临界区),为了避免竞态条件,我们需要让同一时间只有一个进程进入临界区,这时就需要使用信号量来加以限制。
信号量的原理类似于一把锁,临界区类似于一个房间内的资源,当有进程需要使用临界区的资源时,就把房间上锁再使用,这样当其他进程需要用时就只能在房外等待。当使用完毕后,进程需要把锁解开,这样下一个进程就可以进入临界区。
操作系统中对信号量操作一般称为P,V操作,其中P为上锁,V为释放锁。
Linux中,信号量的API定义在sys/sem.h头文件中。主要包括以下三个变量:
- semget
- semop
- semctl
semget系统调用
#include<sys/sem.h>
int semget(key_t key, int num_sems, int sem_flags);
- 作用:用于创建一个新的信号量集,或获取一个已经存在的信号量集
- 参数:
- key:键值,用来标识全局唯一的信号量集,通过信号量通信的进程需要使用相同的键值创建/获取该信号量。
- num_sems:指定要创建的信号量集中数量的数目(如果是获取信号量集,则设为0即可)
- sem_flas:指定一组标志来控制该API的细节,其具体格式和含义与系统调用open的mode参数相同
- 返回值:
- 成功:信号量集的标识符
- 失败:-1
注意,该系统调用有两个作用:
- 创建一个信号量集
- 获得一个已经存在的信号量集
当系统调用是第一个作用时,会将一个相关联的内核数据结构体semid_ds初始化
#include<sys/sem.h>
/*该结构体用于描述IPC对象(信号量,共享内存和消息队列)的权限*/
struct ipc_perm{
key_t key;/*键值*/
uid_t uid;/*所有者的有效用户id*/
gid_t gid;/*所有者的有效组id*/
uid_t cuid;/*创建者的有效用户id*/
git_t cgid;/*创建者的有效组id*/
mode_t mode;/*访问权限*/
/*省略其他*/
}
struct semid_ds{
struct ipc_perm sem_perm;/*信号量的操作权限*/
unsigned long int sem_nsems;/*该信号量集中的信号量数目*/
time_t sem_otime;/*最后一次调用semop的时间*/
time_t sem_ctimel;/*最后一次调用semctl的时间*/
/*省略其他*/
}
初始化的具体数值可自行搜索。
semop系统调用
semop系统调用改变信号量的值,即执行P,V操作。具体的PV操作实际上是对以下内核变量进行操作:
unsigned short semval;/*信号量的值*/
unsigned short semzcnt;/*等待信号量值变成0的进程数量*/
unsigned short semncnt;/*等待信号量值增加的进程数量*/
pid_t sempid;/*最后一次执行semop操作的进程id*/
以下是semop系统调用:
#include<sys/sem.h>
int semop(int sem_id, struct sembuf* sem_ops, size_t num_sem_ops);
- 作用:改变信号量的值,执行PV操作
- 参数:
- sem_id:semget调用返回的信号量集标识符
- sem_ops:一个sembuf结构体类型的数组,见下文单独介绍
- num_sem_ops:指定要执行的操作个数,即sem_ops数组中元素的个数。(semop对sem_ops数组中的每个成员按顺序执行,且该过程时原子操作)
- 返回值:
- 成功:0
- 失败:-1(且sem_ops数组指定的所有操作不执行)
sembuf结构体:
struct sembuf{
unsigned short int sem_num;/*信号量集中信号量的编号,从0开始*/
short int sem_op;/*操作类型,可取正整数,0,负整数,分别代表对信号量不同的操作*/
short int sem_flg;/*影响sem_op的可选值*/
}
sem_op和sem_flg的类型排列组合有些多且繁杂,本文不具体介绍,建议读者真正需要使用时再去了解即可。
semctl系统调用
#include<sys/sem.h>
int semctl(int sem_id, int sem_num, int command, ...);
- 作用:对信号量进行直接的控制
- 参数:
- sem_id:由semget返回的信号量
- sem_num:被操作的信号量在信号量集中的编号
- command:指定信号量要执行的命令
- ...:用户自定义参数,只有个别command需要写,取决于command的取值。
- 返回值:
- 成功:取决于command的取值
- 失败:-1
command参数:
注意,command命令是不要求记忆的,包括我本身也不会去看,放在这里只是为了文章完整性以及查询的作用。这些参数都建议等具体使用的时候再进行查询
共享内存
共享内存是最高效的IPC机制,它不涉及进程间任何的数据传输。与之而来的缺点是,我们必须用其他辅助手段来同步进程对共享进程测访问,否则会产生竞态条件。
Linux中,共享内存API定义在sys/shm.h头文件中,包括4个系统调用:
shmget,shmat,shmdt和shmctl。
shmget系统调用
#include<sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
- 作用:创建一段新的共享内存,或获取一段已经存在的共享内存
- 参数:
- key:键值,用来标识一段全局唯一的共享内存
- size:指定共享内存的大小(如果是获取已经存在的共享内存,则设为0)
- shmflg:指定一组标志来控制该API的细节
- 返回值:
- 成功:正整数值,是 共享内存的标识符
- 失败:-1
同样,当shmget是创建一个共享内存时,与之关联的内核数据结构shmid_ds将被创建并初始化,后面的API的部分功能将修该结构体中的部分参数
struct shmid_ds
{
struct ipc_perm shm_perm;/*共享内存的操作权限*/
size_t shm_segsz;/*共享内存的大小,单位字节*/
time_t shm_atime;/*对这段内存最后一次调用shmat的时间*/
time_t shm_dtime;/*对这段内存最后一次调用shamd的时间*/
time_t shm_ctime;/*对这段内存最后一次调用shmctl的时间*/
pid_t shm_cpid;/*创建者的PID*/
pid_t shm_lpid;/*最后一次执行shmat或shmdt操作的进程的PID*/
shmatt_t shm_nattach;/*目前关联到此共享内存的进程数量*/
/*省略一些字段*/
}
shmat和shmdt系统调用
#include<sys/shm.h>
void* shmat(int shm_id, const void* shm_addr, int shmflg);
- 作用:将共享内存关联到地址空间
- 参数:
- shm_id:由shmget调用返回的共享内存标识符
- shm_addr:指定将共享内存关联到进程的哪块地址空间
- shmflg:影响最终的API的具体细节
- 返回值:
- 成功:返回共享内存被关联到的地址
- 失败:返回 (void*) -1
int shmdt(const void* shm_addr);
作用:将关联到shm_addr的共享内存从进程中分离,成功返回0,失败返回 -1;
shmctl系统调用
#include<sys/shm.h>
int shmctl(int shm_id, int command, struct shmid_ds* buf);
- 作用:控制共享内存的某些属性
- 参数:
- shm_id:由shmget返回共享内存标识符
- command:要执行的命令
- buf:取决于command
- 返回值:
- 成功:取决于command参数
- 失败:-1
command命令:
消息队列
消息队列是两个进程之间传递二进制数据的一种有效的方式,每个数据块都有特定的类型,接收方可以根据类型来由选择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。
Linux中,消息队列的API定义在sys/msg.h头文件中,包括四个系统调用 mssget,msgsnd,msgrcv,msgctl
msgget系统调用
#include<sys/msg.h>
int msgget(key_t key, int msgflg);
- 作用:创建一个消息队列,或者获取一个已有的消息队列
- 参数:
- key:键值,标识一个全局唯一的消息队列
- msgflg:同sem_flags,控制创建消息队列时的细节
- 返回值:
- 成功:返回一个正整数,代表消息队列的标识符
- 失败:-1
同样,当msgget用于创建时,与之关联的内核数据结构msqid_ds将被创建并初始化:
struct msqid_ds {
struct ipc_perm msg_perm; /* 消息队列的操作权限*/
time_t msg_stime; /* 最后一次调用msgsnd的时间*/
time_t msg_rtime; /* 最后一次调用msgrcv的时间*/
time_t msg_ctime; /* 最后一次被修改的时间*/
unsigned long __msg_cbytes; /* 消息队列中已有的字节数*/
msgqnum_t msg_qnum; /* 消息队列中已有的消息数*/
msglen_t msg_qbytes; /* 消息队列最大允许的字节数*/
pid_t msg_lspid; /* 最后执行msgsnd的进程PID*/
pid_t msg_lrpid; /* 最后执行msgrcv的进程PID*/
};
msgsnd系统调用
#include<sys/msg.h>
int msgsnd(int msqid, const void* msg_ptr, size_t msg_sz, int msgflg);
- 作用:将一条消息加入消息队列
- 参数:
- msqid:由msgget调用返回的标识符
- msg_ptr:指针,指向一个准备发送的消息(消息类型见下文)
- msg_sz:消息的数据(mtxt)部分长度
- msgflg:控制msgsnd的行为
- 返回值:
- 成功:0,并修改部分msqid_ds
- 失败:-1
struct msgbuf{
long mtype;/*消息类型*/
char mtext[512];/*消息数据*/
msgrcv系统调用
#include<sys/msg.h>
int msgrcv(int msqid, void *msg_ptr, size_t msg_sz, long int msgtype,int msgflg);
- 作用:从消息队列中获取消息
- 参数:
- msqid:由msgget调用返回的消息队列标识符
- msg_ptr:用于存储接收的消息
- msg_sz:消息数据部分的长度
- msgtype:指定接收何种类型的消息
- msgflg:控制msgrcv函数的行为
- 返回值:
- 成功:0,并修改msqid_ds的部分值
- 失败:-1
msgctl系统调用
#include<sys/msg.h>
int msgctl(int msqid, int command, struct msqid_ds *buf);
- 作用:控制消息队列的某些属性
- 参数:
- msqid:由msgget调用返回的消息队列标识符
- command:要执行的命令
- buf:
- 返回值:
- 成功:取决于command参数
- 失败:-1
command参数:
一些废话
写完这篇文章时已经是2024年的2月1日,距离这个专栏开始(2024年1月12日)已经过去了差不多三周。说实话我并不满意这个速度,在这20天里,我真正的学习的时间只有14天而已。
我每天会带上电脑,早上九点到区图书馆的自习室学习,上午写算法,下午就看书学习写专栏和博客。最初我的设想是晚上九点图书馆闭馆再回家,但往往下午六点吃完晚饭就回去了。我看过同校的一个大佬的学习经历,他在大一的暑期时就已经天天泡市图书馆了。这也是为什么别人早早进大厂,而我却找不到工作的原因。
但我确实是无法一天十二个小时都在学习,我晚上不回去打一会游戏,没多久我就坚持不下去了。包括过去的二十天,因为幻兽帕鲁的开服,我建了个服务器天天晚上都和室友在玩,每天晚上玩两三个小时的游戏是我生活的聊聊慰藉。
除了和朋友玩,我每天睡觉前还要和异地的对象玩金铲铲之战。她本来是完全不玩游戏的,我强行拉她入坑,现在她每天晚上拉我玩,不然我真不知道异地的这些日子怎么维系感情,我白天忙学习,晚上玩游戏,本就没时间和她聊天,要是没有金铲铲,估计感情用不了多久就GG了。(感谢金铲铲)
回到正题,我现在的学习规划是在这段时间同步把这个Webserver的项目和Carl的代码随想录做完。在学有余力的情况下,学习Linux的常用操作命令(应该会再开一个专栏)。
下一个阶段的学习计划是开始看哈工大的 OS网课,保持算法手感的同时开始做OS的项目(具体做MIT6.S081还是操作系统真象还原还不确定)。
等两个项目都完成后,开始大量背八股,投简历,投实习,希望能在4月前找到个实习吧。
end