C++ Webserver从零开始:基础知识(七)——多进程编程

前言

        在学习操作系统时,我们知道现代计算机往往都是多进程多线程的,多进程和多线程技术能大大提高了CPU的利用率,因此在web服务器的设计中,不可避免地要涉及到多进程多线程技术。

        这一章将简要讲解web服务器中的多进程编程,本文不会很详细,也不会在原理性的知识上多费笔墨。如果读者有什么不理解的地方,建议学习一下操作系统的基础知识。


fork系统调用

#include
#include
pid_t fork(void);
  • 作用:复制当前进程,在内核进程表创建一个新的表项。新表项许多属性与原进程相同,比如堆指针,栈指针和标志寄存器的值。新进程的PPID为原进程的PID,信号位图被清除(原进程的信号处理函数对新进程不起作用;
  • 参数:无
  • 返回值(两次):一般根据fork返回值判断正在执行这段代码的是新进程还是原进程
    • 父进程中:返回子进程的PID
    • 子进程中:返回0

fork的一些注意事项:

  1. 子进程的代码与父进程完全相同
  2. 子进程采用写时复制的方式复制父进程的数据(堆数据,栈数据和静态数据)
  3. 父进程打开的文件描述符在子进程中同样打开,每fork一次文件描述符的全局引用+1


exec系统调用

        上面的fork是复制出一个新进程,类似于ctrl + c 和 ctrl + v,而exec系统调用则类似于 ctrl + x, ctrl + v。

#include
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不会关闭原程序打开的文件描述符


僵尸进程

        多进程中父进程一般需要跟踪子进程的退出状态,所以子进程退出时内核一般不会立刻释放其资源。这会出现以下两种情况:

  1. 子进程运行结束了,父进程还未读取其状态
  2. 父进程异常终止了,子进程继续运行直至结束,守护进程还未释放其资源
    1. 这时子进程被称为孤儿进程,孤儿进程会被守护进程init(PID为1)所收养,即其PPID被设为1
    2. 守护进程init会等待孤儿进程结束并释放其资源

        处于以上两种状态的僵尸进程,僵尸进程会占据内核的资源却不行使任何功能,所以我们要避免僵尸进程。方法是wait调用释放僵尸进程.

#include
#include
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,本文不予介绍。


信号量

信号量原语

        在学习操作系统的时候相信大家都了解了信号量,这里简单介绍一下。

        当多个程序需要访问系统上的某一个资源时(通常为很短的一段代码,称为临界区),为了避免竞态条件,我们需要让同一时间只有一个进程进入临界区,这时就需要使用信号量来加以限制。

        信号量的原理类似于一把锁,临界区类似于一个房间内的资源,当有进程需要使用临界区的资源时,就把房间上锁再使用,这样当其他进程需要用时就只能在房外等待。当使用完毕后,进程需要把锁解开,这样下一个进程就可以进入临界区。

        操作系统中对信号量操作一般称为P,V操作,其中P为上锁,V为释放锁。

Linux中,信号量的API定义在sys/sem.h头文件中。主要包括以下三个变量:

  • semget
  • semop
  • semctl

semget系统调用

#include
int semget(key_t key, int num_sems, int sem_flags);
  • 作用:用于创建一个新的信号量集,或获取一个已经存在的信号量集
  • 参数:
    • key:键值,用来标识全局唯一的信号量集,通过信号量通信的进程需要使用相同的键值创建/获取该信号量。
    • num_sems:指定要创建的信号量集中数量的数目(如果是获取信号量集,则设为0即可)
    • sem_flas:指定一组标志来控制该API的细节,其具体格式和含义与系统调用open的mode参数相同
  • 返回值:
    • 成功:信号量集的标识符
    • 失败:-1

注意,该系统调用有两个作用:

  1. 创建一个信号量集
  2. 获得一个已经存在的信号量集

当系统调用是第一个作用时,会将一个相关联的内核数据结构体semid_ds初始化

#include
/*该结构体用于描述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
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
int semctl(int sem_id, int sem_num, int command, ...);
  • 作用:对信号量进行直接的控制
  • 参数:
    • sem_id:由semget返回的信号量
    • sem_num:被操作的信号量在信号量集中的编号
    • command:指定信号量要执行的命令
    • ...:用户自定义参数,只有个别command需要写,取决于command的取值。
  • 返回值:
    • 成功:取决于command的取值
    • 失败:-1

command参数:

C++ Webserver从零开始:基础知识(七)——多进程编程_第1张图片

注意,command命令是不要求记忆的,包括我本身也不会去看,放在这里只是为了文章完整性以及查询的作用。这些参数都建议等具体使用的时候再进行查询


共享内存

共享内存是最高效的IPC机制,它不涉及进程间任何的数据传输。与之而来的缺点是,我们必须用其他辅助手段来同步进程对共享进程测访问,否则会产生竞态条件。

Linux中,共享内存API定义在sys/shm.h头文件中,包括4个系统调用:

shmget,shmat,shmdt和shmctl。

shmget系统调用

#include
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
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
int shmctl(int shm_id, int command, struct shmid_ds* buf);
  • 作用:控制共享内存的某些属性
  • 参数:
    • shm_id:由shmget返回共享内存标识符
    • command:要执行的命令
    • buf:取决于command
  • 返回值:
    • 成功:取决于command参数
    • 失败:-1

command命令:

C++ Webserver从零开始:基础知识(七)——多进程编程_第2张图片


消息队列

消息队列是两个进程之间传递二进制数据的一种有效的方式,每个数据块都有特定的类型,接收方可以根据类型来由选择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。

Linux中,消息队列的API定义在sys/msg.h头文件中,包括四个系统调用 mssget,msgsnd,msgrcv,msgctl

msgget系统调用

#include
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
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
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
int msgctl(int msqid, int command, struct msqid_ds *buf);
  • 作用:控制消息队列的某些属性
  • 参数:
    • msqid:由msgget调用返回的消息队列标识符
    • command:要执行的命令
    • buf:
  • 返回值:
    • 成功:取决于command参数
    • 失败:-1

command参数:

C++ Webserver从零开始:基础知识(七)——多进程编程_第3张图片

一些废话

写完这篇文章时已经是2024年的2月1日,距离这个专栏开始(2024年1月12日)已经过去了差不多三周。说实话我并不满意这个速度,在这20天里,我真正的学习的时间只有14天而已。

我每天会带上电脑,早上九点到区图书馆的自习室学习,上午写算法,下午就看书学习写专栏和博客。最初我的设想是晚上九点图书馆闭馆再回家,但往往下午六点吃完晚饭就回去了。我看过同校的一个大佬的学习经历,他在大一的暑期时就已经天天泡市图书馆了。这也是为什么别人早早进大厂,而我却找不到工作的原因。

但我确实是无法一天十二个小时都在学习,我晚上不回去打一会游戏,没多久我就坚持不下去了。包括过去的二十天,因为幻兽帕鲁的开服,我建了个服务器天天晚上都和室友在玩,每天晚上玩两三个小时的游戏是我生活的聊聊慰藉。

除了和朋友玩,我每天睡觉前还要和异地的对象玩金铲铲之战。她本来是完全不玩游戏的,我强行拉她入坑,现在她每天晚上拉我玩,不然我真不知道异地的这些日子怎么维系感情,我白天忙学习,晚上玩游戏,本就没时间和她聊天,要是没有金铲铲,估计感情用不了多久就GG了。(感谢金铲铲)

回到正题,我现在的学习规划是在这段时间同步把这个Webserver的项目和Carl的代码随想录做完。在学有余力的情况下,学习Linux的常用操作命令(应该会再开一个专栏)。

下一个阶段的学习计划是开始看哈工大的 OS网课,保持算法手感的同时开始做OS的项目(具体做MIT6.S081还是操作系统真象还原还不确定)。

等两个项目都完成后,开始大量背八股,投简历,投实习,希望能在4月前找到个实习吧。

end

你可能感兴趣的:(c++,服务器)