【C++】Linux多进程学习笔记

文章目录

  • 1 创建进程(fork函数)
  • 2 进程退出(exit函数)
  • 3 父子进程变量是否共享?(虚拟地址空间)
  • 4 异常进程
    • 4.1 孤儿进程
    • 4.2 僵尸进程
  • 5 回收子进程
    • 5.1 wait()函数——等待任意一个子进程结束
    • 5.2 waitpid()函数——等待指定子进程结束(可以设置是否阻塞)
  • 6 进程间通信
    • 6.1 进程间通信之管道
      • 6.1.1 匿名管道
      • 6.1.2 有名管道
    • 6.2 进程间通信之内存映射
      • 6.2.1 mmap使用内存映射
      • 6.2.2 munmap释放内存映射
      • 6.2.3 使用内存映射实现进程间的通信
      • 6.2.4 匿名映射
    • 6.3 进程间通信之信号
      • 6.3.1 signal的细节
      • 6.3.2 信号集
      • 6.3.3 对自定义的信号集的处理函数
      • 6.3.4 将自定义信号集应用到内核的阻塞信号集
      • 6.3.5 发送信号——kill、raise、abort函数
      • 6.3.6 设置定时器——alarm、setitimer函数
      • 6.3.7 回调函数
      • 6.3.8 信号捕捉函数——sigaction函数
        • 函数介绍
        • sigaction结构体
        • 注意捕捉时的注意事项
    • 6.4 进程间通信之共享内存
      • 6.4.1 共享内存的使用步骤
      • 6.4.2 操作共享内存的相关函数
      • Q & A
  • 7 守护进程
    • 7.1 概念
    • 7.2 创建步骤
  • 8 引用

1 创建进程(fork函数)

用于创建子进程。

 #include 
 #include 
 	pid_t fork(void)

返回值:fork()的返回值会返回两次。一次是在父进程中,一次是在子进程中.
在父进程中返回创建的子进程的ID,在子进程中返回0。
【如何区分父进程和子进程】通过fork的返回值。
【注意1】在父进程中返回-1,表示创建子进程失败,并且设置errno

在实际的fork之后,就相当于两个进程去同时执行下面的代码了。如果想要父进程和子进程分别做自己想做的,就可以通过fork返回的pid号来if区别执行程序。
例如:

pid_t pid = fork();
if(pid > 0){
cout << "i am parent process" << endl;
}
else if(pid == 0) {
cout << "i am  child process" << endl;
}

【注意2】如果直接多个fork,那么其实会产生2的n(fork函数的个数)次方-1个子 进程。如

fork();
fork();
//执行结束后,总共有四个进程(包括主进程)

如果只想创造两个子进程,则可以:

pid_t pid;
for(int i = 0; i < 2; i++) {
fork();
if(pid == 0) break;
}
//总共三个进程(包括主进程)

2 进程退出(exit函数)

这个函数是属于c语言的函数:

#include 
 void exit(int status);

status是进程退出时的一个状态信息。父进程回收子进程资源的时候可以获取到。
其执行的流程如下:

  1. 进程运行
  2. 调用退出处理函数
  3. 刷新I/O缓冲,并关闭文件描述符
  4. 调用_exit()系统调用
  5. 进程终止运行

3 父子进程变量是否共享?(虚拟地址空间)

理论上,fork之后,子进程的用户区数据和父进程是一样的。内核区数据除了pid号也是一样的,也就是拷贝过去。
但为什么说是“理论上”呢?
因为实际上采用的是《读时共享,写时复制》的技术,即:

  • 当子进程只有可读操作时,其实并没有开辟任何新的空间,而是与父进程享用一个空间,访问子进程的一些属性,就会映射到父进程的那块区域。
  • 当子进程出现需要更改的地方时(与父进程要不一样了),在低层会重新在真实物理内存中创建一个空间保存数据,然后子进程中的虚拟地址映射到新的物理内存。

4 异常进程

4.1 孤儿进程

【定义】父进程运行结束.但子进程还在运行(未运行结束),这样的子进程就称为孤儿进程(orphan Process) 。

  • 每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为iniI,而init进程会循环地 wait()它的已经退出的子进程。因此,当孤儿进程结束的时候,其内核的空间就回由init进程来帮忙释放了。
  • 因此孤儿进程时没有什么危害的。

4.2 僵尸进程

每个进程结束之后,都会释放自己地址空间中的用户区数据,内核区的 PCB没有办法自己释放掉,需要父进程去释放。当子进程终止时,父进程(可能仍然在忙)尚未回收.子进程残留资源(PCB)存放于内核中,变成僵尸( Zombie)进程。
PS:僵尸进程不能被kill -9杀死。
这样就会导致一个问题,如果父进程不调用wait()waitpid ()的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害,应当避免。

5 回收子进程

5.1 wait()函数——等待任意一个子进程结束

调用wait函数的进程会被挂起(阻塞),知道它的一个子进程退出或者收到一个不能被忽略的信号(与下面的信号量有关)时,才被唤醒(相当于继续向下执行)。若没有子进程或者子进程都已经结束了,则会返回-1。

#include 
#include 
pid_t wait(int *wstatus);

其中,wstatus是指进程退出时的状态信息,传入的时一个int类型的地址,传出参数(即子进程回改变这个状态值返回出来)。[若为null则表示不在乎子进程返回什么]
【返回值】

  • 成功:返回被回收的子进程的id
  • 失败:就返回-1(代表所有子进程都结束OR调用函数失败),会不阻塞直接返回这个-1
    【功能】等待任何一个子进程结束,如果任意一个子进程结束了,次函数会回收子进程的资源。

5.2 waitpid()函数——等待指定子进程结束(可以设置是否阻塞)

回收指定进程号的子进程,可以设置是否阻塞。

#include 
#include 
pid_t waitpid(pid_t pid, int *wstatus, int options);

【参数】

  • pid参数:
    • pid > 0: 回收指定pid号的某个子进程的pid
    • pid = 0:回收当前进程组的所有子进程
    • pid = -1:回收所有的子进程,相当于调用wait(最常用)
    • pid < -1: 回收指定某个进程组(组id的绝对值,如回收2组,那么为-2)中的子进程
  • options: 设置阻塞或者非阻塞
    • 0:阻塞
    • WNOHANG:非阻塞(会继续向下执行 )

【返回值】

  • >0 : 返回子进程的id
  • 0 : 出现在非阻塞的情况里options=WNOHANG,表示还有子进程。
  • -1 :错误,或者表示没有子进程了

6 进程间通信

主要分为两大类:

  1. 同一主机进程间通信
    • Unix进程间通信方式
      • 匿名管道
      • 有名管道
      • 信号
    • System V进程间通信方式以及POSIX进程间通信方式
      • 消息队列
      • 共享内存
      • 内存映射
      • 信号量
  2. 不同主机(网络)进程间通信
    • Socket

6.1 进程间通信之管道

6.1.1 匿名管道

管道也叫无名(匿名)管道,它是是UNIX系统IPC(进程间通信)的最古老形式,所有的UNIX系统都支持这种通信机制。
【案例】统计一个目录中文件的数目命令: ls | wc -l,为了执行该命令, shell创建了两个进程来分别执行ls(获得目录) 和wc(统计个数)。
由ls产生得到的内容的进程,通过|管道符,从(管道写段)将内容传送给wc的进程(管道读端)。
【特点】

  • 管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的, 不同的操作系统大小不一定相同。
  • 管道拥有文件的特质:读操作、写操作,匿名管道没有文件实体(因此常用于父子进程),有名管道有文件实体(常用于无关系的进程),但不存储数据。可以按照操作文件的方式对管道进行操作。
  • 一个管道是一个字节流,使用管道时不存在消息(有规定好的固定的头和体组成,如后面的网络协议)或者消息边界的概念。从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。
  • 通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的。
  • 在管道中的数据的传递方向是单向的, 一端用于写入,一端用于读取,管道是半双工(即没法同时又读又写,一端为读,另一端必须为写)的。
  • 从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用lseek ()来随机的访问数据。
  • 匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘之间使用;并且要注意,要在fork之前得到管道的描述符

【为什么可以使用匿名管道进行进程间通信】
因为父进程刚fork时,一开始的文件描述符都是共享的。而管道就是相当于把这个共享的文件描述符换成了管道。因此可以实现一个写一个读。
【管道的数据结构】
由线性队列来设计的循环队列。有一个读指针和一个写指针。
【使用】
创建一个匿名管道,用来进程间通信。

#include 
int pipe(int pipefd[2]);
//成功则返回0,失败则返回-1
  • pipefd[2]这个数组是一个传出参数。我们把这个数组传递进去,该函数会把管道创建好,并且把管道的两端对应的文件描述符存进去。
    • pipedf[0] 对应的是管道的读端(注意,管道默认是阻塞的,如果管道中没有数据,read()函数阻塞)
    • pipedf[1] 对应的是管道的写端(注意,管道默认是阻塞的,如果管道满了,write()函数阻塞)

【查看管道缓冲大小】shell命令

ulimit -a
# 会显示有几块,以及每块的字节大小

该命令的中间一列会出现-x的选项,通过加该选项,便可修改其类型的大小。例如:
【C++】Linux多进程学习笔记_第1张图片
那么此时,如果我向修改pipe size的值,由8改为16。那么我可以通过
ulimit -p 16来修改。

【注意事项】

  1. 一般匿名管道不会用于进程的互相通信,通常是一个发一个收。例如:如果子进程写一句话到管道,而下面是用read,如果有sleep的话,根据调度算法,父进程可以先read,但是如果没sleep,那么直接就是子进程写,子进程读,没意义了!因此,如果父进程主要的话,就要close(pipefd[0])把读端关闭。
  2. 若管道是阻塞的行为,对于读端:
    • 若写端的文件描述符没有关闭,并且持有管道写端的进程也没有往里写数据,这个时候进程调用read的时候,会阻塞
    • 若所有指向管道写段的文件描述符都关闭了,当管道中的剩余数据被读取后,再使用read不会阻塞,而是直接返回0(因为read返回实际读到的字节数),像读到文件末尾一样。
  3. 若管道是阻塞的行为,对于写端:
    • 若写端的文件描述符没有关闭,并且持有管道读端的进程也没有往里读数据,在管道被写满时,这个时候进程调用write的时候,会阻塞,直到管道中有空位置才能再次写入数据并返回。
    • 若所有指向管道读段的文件描述符都关闭了,这个时候有向管道中写数据,那么该进程会收到一个信号SIGPIPE,通常会导致进程异常终止。
  4. 上面2和3的行为可以通过fcntl函数来将管道的文件描述符设置为非阻塞来避免。

6.1.2 有名管道

匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道(FIFO) ,也叫命名管道、FIFO文件。

  • 有名管道(FIFO)不同于匿名管道之处在于它提供了一个路径名与之关联,以FIFO的文件形式存在于文件系统中,并且其打开方式与打开一个普通文件是一样的,这样即使与FIFO的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过FIFO相互通信,因此,通过FIFO 不相关的进程也能交换数据(当然,有亲缘关系的进程更可以使用FIFO来通信)。
  • 一旦打开了 FIFO(即创建好了FIFO,用open函数打开它),就能在它上面使用与操作匿名管道和其他文件的系统调用一样的I/o系统调用了(如read()write()close())。与管道―样,FIFO 也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO的名称也由此而来:先入先出
    【特点】
    FIFO和pipe大部分特点相同,但也有三点是不同的:
  • FIFO在文件系统中作为一个特殊文件存在,但FIFO中的内容却存放在内存(也是内核的缓冲区)中。
  • 当使用FIFO的进程退出后, FIFO文件将继续保存在文件系统中以便以后使用。
  • FIFO有名字,不相关的进程可以通过打开有名管道进行通信。
  • FIFO 严格遵循先进先出 (First in First out),对管道及FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。

【使用之创建】创建fifo文件的两个方法:
1、使用shell命令

mkfifo XXX(名字)

2、使用函数

#include 
#include 
int mkfifo(const char *pathname, mode_t mode);
//成功创建则返回0,失败返回-1并设置errno

参数:

  • pathname: 管道名称的路径(相对/绝对路径均可)
  • mode: 文件的权限(和open的mode是一样的,例如:inoutapp(尾部添加数据)、trunc(清空内部存储的所有数据)、ate(打开一个已有的文件,并将文件读指针指向文件末尾)等选项)。

【使用之打开】打开管道直接就使用open()打开管道,可以根据是该进程操作管道写还是读,来决定open()的第二个参数是只读还是只写。
【注意事项】

  1. 但是需要注意的是,如果open以只读或只写的模式打开时,如果另一端的只写或者只读没打开,那么就会在open处进行阻塞
  2. fifo的注意事项和pipe的注意事项2、3情况都是一样的!例如:若所有指向管道读段的文件描述符都关闭了(即另一个进程的read关闭了),这个时候有向管道中写数据,那么该进程会收到一个信号SIGPIPE,通常会导致写进程异常终止。
    【案例】使用有名管道完成两个进程聊天的功能:
    对于进程A和进程B来创建两个管道:
  • 对于进程A
    • 以只读方式打开管道1
    • 以只写方式打开管道2
    • 循环的读写数据(注意要先写再读,防止两个进程一起阻塞)
  • 对于进程B
    • 以只写方式打开管道1
    • 以只读方式打开管道2
    • 循环的读写数据(注意要先读再写,防止两个进程一起阻塞)
  • 注意上述两个进程只能一个写,另一个写完,这个再写,原因就是因为一个进程同时循环读写是会阻塞的。如果一个进程想要无限制地去写就要将循环读写的那部分分成父子进程去协同操作。

6.2 进程间通信之内存映射

内存映射(Memory-mapped I/O)是将磁盘文件的数据映射到内存,用户通过修改
内存就能修改磁盘文件。因此,只要两个进程同时映射同一个内存区域,从而实现通信。

6.2.1 mmap使用内存映射

将一个文件或者设备的数据映射到内存中

#include 
void* mmap(void* addr,size_t length,int prot,int flags,int fd, off_t offset);
//成功,返回创建的内存的首地址
//失败,则返回MAP_FAILED---这个其实就是(void*) -1。

参数:

  • addr是要映射到的地址,一般直接设为NULL,由系统的内核指定即可。
  • length:是要映射的数据的长度,这个值不能为0。建议使用文件的长度(可以使用stat或者lseek函数【lseek(fd, 0, SEEK_END)】去获取文件的长度)。PS:这个并不是给多少,就是多少。是会以分页的大小为单位,生成一个整数倍的length。
  • prot:对申请的内存映射区的操作权限(多个权限用|运算符来并起来)
    • PROT_READ 读权限(要操作映射内存,起码要有个读权限)
    • PROT_EXEC 可执行权限
    • PROT_WRITE 写权限
    • PROT_NONE 没有权限
  • flags:指定映射对象的类型,映射选项和映射页是否可以共享
    • MAP_SHARED 映射区的数据会自动和磁盘文件进行同步,进程间通信,必须要设置这个选项!
    • MAP_PRIVATE 不同步,内存映射区的数据改变了,对原来的文件也不会修改,会重新创建一个新的文件(copy on write)。
    • MAP_FIXED:对映射区所作的修改会反映到物理设备,但需要调用msync()或者munmap();
  • fd:需要映射的那个文件的文件描述符
    • 通过open得到,open的是一个磁盘文件
    • 注意:文件的大小不能为0,并且open指定的权限不能和prot参数有冲突
      • prot:PROT_READ open:只读/读写
      • prot:PROT_READ | PROT_WRITE open:读写
  • offset:被映射文件的偏移量,一般设为0,表示从头开始映射。(一般使用0,否则该字必须为4的整数倍)

6.2.2 munmap释放内存映射

用完内存映射记得释放。

int munmap(void *addr, size_t length);

参数:

  • addr:要释放的内存的首地址
  • length:要释放的内存的大小,要和mmap函数中的length参数的值一样

6.2.3 使用内存映射实现进程间的通信

  1. 有关系的进程(父子进程)
    • 还没有子进程的时候,通过唯一的父进程创建好内存映射
    • 有了内存映射后,创建子进程
    • 父子进程共享创建的内存映射区
  2. 没有关系的进程间通信
    • 准备一个大小不是0的磁盘文件
    • 进程1先通过磁盘文件创建内存映射区,并得到一个操作这块内存的指针
    • 进程2也过磁盘文件创建内存映射区,并得到一个操作这块内存的指针
    • 使用该内存映射区进行通信
  3. 注意:内存映射区通信是非阻塞的。
  4. 注意:当mmap后关闭文件描述符,对mmap映射是没有任何影响的。
  5. 注意:当ptr越界操作时,会操作非法的内存,从而引发段错误。

【案例】使用内存映射实现文件拷贝的功能(因为内存不大,所以并不常用)
思路:

  1. 对原始的文件进行内存映射
  2. 创建一个新的文件(因为刚创建好的长度为0,因此映射区长度也会为0,所以要对该文件进行拓展truncate("cpy.txt", len)
  3. 把新文件的数据映射到内存中
  4. 通过内存拷贝,将第一个文件的内存数据,拷贝到新的文件内存中
  5. 释放资源

6.2.4 匿名映射

不需要文件实体进程一个内存映射,因此也只能是用于父子进程之间的通信。
只不过要在flag上加入选项MAP_ANONYMOUS,fd随意指定(反正会忽略),然后偏移量必须为0。即:

mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

6.3 进程间通信之信号

信号是 Linux进程间通信的最古老的方式之一,是事件发生时对进程的通知机制,有时也称之为软件中断.它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式
【概念】信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断.转而处理某一个突发事件(发往进程的诸多信号,通常都是源于内核)。
【使用信号的两个主要目的】

  • 让进程直到已经发生了一个特定的事情。
  • 强迫进程执行它自己代码中的信号处理程序。
    【常见信号】
信号名称 对应的事件 默认动作
SIGINT 当用户按下了组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号 终止进程
SIGQUIT 当用户按下了组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出此信号 终止进程
SIGKILL 无条件终止进程。该信号不能被忽略,处理和阻塞 终止进程
SIGSEGV 指示进程进行了无效内存访问(段错误) 终止进程并产生core文件
SIGPIPE pipe向一个没有读端的管道写数据 终止进程
SIGCHID 子进程结束时,父进程会收到这个信号 忽略这个信号(父进程可以靠处理这个信号来消除僵尸进程)
SIGCONT 如果进程已停止,则使其继续运行 忽略或者继续
SIGSTOP 停止进程的执行。信号不能被忽略,处理和阻塞 暂停进程

6.3.1 signal的细节

【信号的五种默认处理动作】

  • Term 终止进程
  • Ign 当前进程忽略掉这个信号
  • Core 终止进程,并生成一个Core文件(就和python中的错误信息一样,会将中断的点及其信号返回出来)
  • Stop 暂停当前进程
  • Cont 继续执行当前被暂停的进程

【信号的几种状态】
产生、未决(还没到达)、递达(已到达处理)

6.3.2 信号集

许多信号相关的系统调用都需要能表示一组不同的信号,多个信号可使用一个称之为信号集的数据结构来表示,其系统数据类型为sigset_t

在PCB中有两个非常重要的信号集。一个称之为“阻塞信号集”(处于未决之后,但未被处理,它是能够人为产生的),另一个称之为"未决信号集”(处于未决的状态,它无法人为地产生)。这两个信号集都是内核使用位图机制(即二进制的每一个位表示一个信号是否处于这个集合所代表的状态)来实现的。但操作系统不允许我们直接对这两个信号集进行位操作。而需自定义另外一个集合,借助信号集操作函数来对PCB中的这两个信号集进行修改。

而两个信号集的关系为:
这个未决状态的信号,需要被处理,处理之前需要和另一个信号集(阻塞信号集)﹐进行比较。如果没阻塞就会被处理;否则该信号会继续处于未决状态直至阻塞解除。

  • 阻塞信号集默认不阻塞任何的信号
  • 如果想要阻塞某些信号需要用户调用系统的API

6.3.3 对自定义的信号集的处理函数

【创建一个信号集】这个信号集可以充当阻塞信号集!(通过《下章节介绍的函数》,系统内核根据这个定义在用户区的信号集区修改内核的阻塞信号集)也可以充当未决信号集(通过《下章节介绍的函数》,来承接未决信号集的信息。)
sigset_t set;
【sigemptyset函数】
清空信号集中的数据,将信号集中的所有的标志位置0

int sigemptyset(sigset_t *set);
//成功返回0,失败返回-1
  • set指传出参数,需要操作的信号集

【sigfillset函数】
将信号集中所有的标志位置1

int sigfillset(sigset_t *set);
//成功返回0,失败返回-1
  • set指传出参数,需要操作的信号集

【sigaddset函数】
-功能:设置信号集中的某一个信号对应的标志位为1(也可以看作将该信号加入到自定义的信号集中)

int sigaddset(sigset_t *set, int signum);
//成功返回0,失败返回-1
  • set: 指传出参数,需要操作的信号集
  • signum:需要设置阻塞的那个信号

【sigdelset函数】
-功能:设置信号集中的某一个信号对应的标志位为0(也可以看作将该信号在自定义的信号集中删掉)

int sigdelset(sigset_t *set, int signum);
//成功返回0,失败返回-1
  • set: 指传出参数,需要操作的信号集
  • signum:需要设置不阻塞的那个信号
    【sigismember函数】
    -功能:判断某个信号在信号集的位置是0还是1(也可以看作是判断该信号是否在自定义的信号集中)
int sigismember(const sigset_t *set, int signum ) ;
//1表示signum位为1;0表示signum位为0;-1表示调用失败
  • set: 需要查询的信号集(这个不是传出参数)
  • signum:需要判断的那个信号

6.3.4 将自定义信号集应用到内核的阻塞信号集

将自定义信号集应用到内核的阻塞信号集(设置阻塞,解除阻塞,替换)
【sigprocmask函数】

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
//成功返回0,失败返回-1,并设置错误号(EFAULT、EINVAL)

参数:

  • how:如何对内核阻塞信号集进行处理
    • SIG_BLOCK:将用户设置的阻塞信号集添加到内核中,内核中原来的数据不变(假设内核的阻塞信号集为mask,则操作为mask | set)
    • SIG_UNBLOCK:根据用户设置的信号集位,对内核中原来的数据进行解除阻塞(假设内核的阻塞信号集为mask,则操作为mask &= ~set))
    • SIG_SETMASK:覆盖内核中原来的值
  • set:已经初始化好的用户自定义的信号集
  • oldset:保存的设置之前的内核中的阻塞信号集的状态,常为NULL

【sigpending】
获取内核中的未决信号集

int sigpending(sigset_t *set);
//成功返回0,失败返回-1
  • set是一个传出参数,它保存的是内核中的未决信号集的信息

6.3.5 发送信号——kill、raise、abort函数

【kill函数】功能:给任何进程和进程组pid,发送任何信号sig

#include 
#include 
int kil1(pid_t pid, int sig);
//成功则返回0,否则返回非0

参数:

  • pid:需要发送给的进程的id
    • 若为正值,则发送给某个进程
    • 若为0, 则发送给当前的进程组
    • 若为-1,则发送每一个有权限接收这个信号的进程
    • 若 < -1,则发送给器绝对值的id的某个进程组
  • sig:需要发送的信号的编号或者是宏值(上面提到的常见信号),0表示不发送任何信号

【raise函数】
给当前进程发送信号

#include 
int raise(int sig);
//成功则返回0,否则返回非0

参数:

  • sig:需要发送的信号的编号或者是宏值(上面提到的常见信号)

【abort函数】
发送SIGABRT信号给当前的进程,杀死当前进程

void abort(void);

6.3.6 设置定时器——alarm、setitimer函数

设置定时器。函数调用,开始倒计时,当倒计时为0的时候,函数会给当前的进程发送一个信号:SIGALARM(默认终止当前的进程,每一个进程都有且仅有唯一的一个定时器)
【alarm函数】一次定时该函数并不阻塞,并且无论进程处于什么状态,alarm都会计时(alarm的定时时间包含的是:用户+系统内核的运行时间)这里的系统内核的运行时间就例如打印到屏幕等的操作。

#include ;
unsigned int alarm(unsigned int seconds);
//返回值有两种
// 之前没有定时器,返回0
// 之前有定时器,返回之前的定时器剩余的时间

参数:

  • seconds:倒计时的时长,单位:秒。如果参数为0,定时器无效(不进行倒计时,也不发生信号)。因此当取消一个定时器时,通过ararm(0)

【注意】当alarm出现多次时,最后一次将覆盖上一次(上一次直接失效)。

int second = alarm(10) //second为0
过了一秒
econd = alarm(3) // second为9
等三秒后,进程终止

【setitimer函数】周期性定时, 该函数并不阻塞
设置定时器。可以替代alarm函数,精度可以到微秒。并可以周期性定时。

#include 
int getitimer(int which, struct itimerval *curr_value);
int setitimer(int which, const struct itimerval *new_value,
               struct itimerval *old_value);
//成功返回0,失败返回-1并设置错误号

参数:

  • which:定时器以什么时间计时
    • ITIMER_REAL:真实时间(即上面alarm的时间),时间到达,发送 SIGALARM信号
    • …其余不常用,此处不介绍了
  • new_value:设置定时器的属性(可以设置好每个阶段的时间——间隔时间,以及延长多长时间执行定时器)
  • old_value:记录上一次的定时的时间参数(一般不使用,指定NULL)
    上面提到的结构体,具体如下
struct itimerval {	//定时器的结构体
struct timeval it_interval;	//每个阶段的时间,间隔时间
struct timeval it_value;	//延迟多长时间执行定时器
};
struct timeval {	//时间的结构体
time_t tv_sec;	//秒数
suseconds_t tv_usec;	//微秒
};

【案例】过3s之后,每隔2s定时一次

struct itimerval new_val;
//设置间隔时间
new_val.it_interval.tv_sec = 2;
new_val,it_interval.tv_usec = 0;
//设置延迟时间
new_val.it_value.tv_sec = 3;
new_val,it_value.tv_usec = 0;
setitimer(ITIMER_REAL, &new_val, NULL);

6.3.7 回调函数

  • 需要程序员实现,提前准备好的,函数的类型根据实际需求,看函数指针的定义
  • 这个函数不是程序员调用,而是当信号产生,有内核调用。
  • 函数指针是实现回调的手段,函数实现之后,将函数名放到函数指针的位置就可以了。

6.3.8 信号捕捉函数——sigaction函数

函数介绍

捕捉到信号,检查或改变信号的处理。

#include 
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
//返回值,0 表示成功,-1 表示有错误发生。
  • signum需要捕捉的信号的编号或者宏值(信号的名称)。【ps:SIGKILL和SIGSTOP是不可以被捕捉的】
  • act:捕捉到信号之后的处理动作。
  • oldact:上一次对信号捕捉的相关的设置,一般不使用,传递NULL。

sigaction结构体

struct sigaction {
	//这里的int指的是回调函数可以有int传入,传入的值就是捕捉到的信号编号
    void (*sa_handler)(int);
    void (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
}

参数解释如下:

  • sa_handler是一个函数指针,指向的函数就是信号捕捉到之后的处理函数
  • sa_sigaction同样是信号处理函数,有三个参数,可以获得关于信号更详细的信息(不常用
  • sa_mask 临时阻塞信号集,信号捕捉函数执行过程中,临时阻塞某些信号(使用时,一般通过sigemptyest(&act.sa_mask)来清空临时阻塞信号集)
  • sa_flags 指示“使用哪一个信号处理函数”对捕捉到的信号进行处理
    • 0,表示使用sa_handler作为信号处理函数
    • SA_SIGINFO,使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数
  • sa_restorer已经废弃掉了

注意捕捉时的注意事项

  1. 当多个进程同时返回一个信号时,由于未决信号集的标志位只能0或者1,因此当多个信号同时返回时,只会处理一个信号。
    • 因此,例如遇到像SIGCHID 此类提醒父进程要去回收的信号时,应循环1来调用非阻塞的waitpid,通过判断标志位查看是否回收完或者是否需要退出循环。
  2. 实行sigaction函数时,将回调函数注册到内核是需要时间的。如果注册进内核这个期间,该信号已经被捕获,那么将报错。
    • 因此,在使用sigaction函数的时候,提前设置好阻塞信号集,将要捕获的信号先设置阻塞状态
    • 当使用完sigaction函数,即注册完信号捕捉函数之后,再去通过sigprocmask解除阻塞。

6.4 进程间通信之共享内存

共享内存允许两个或者多个进程共享物理内存的同一块区域(通常被称为段)。由于一个共享内存段会称为一个进程用户空间的一部分,因此这种 IPC(进程间通信)机制无需内核介入。

所有需要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。

与管道等要求发送进程将数据从用户空间的缓冲区复制进内核内存和接收进程将数据从内核内存复制进用户空间的缓冲区的做法相比,这种 IPC技术的速度更快(比内存映射的效率都高(因为内存映射多了一步往磁盘写文件的过程)。

缺点就是会存在进程同步的问题

6.4.1 共享内存的使用步骤

  1. 调用shmget创建一个新共享内存段或取得一个既有共享内存段的标识符(即由其他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。
  2. 使用shmat来将进程和共享内存段关联起来,即使该段成为调用进程的虚拟内存的一部分。
  3. 此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由shmat调用返回的 addr值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针。
  4. 调用shmdt来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。
  5. 调用shmctl来删除共享内存段。只有当当前所有附加内存段的进程都与之分离之后内存段才会销毁。只有一个进程需要执行这一步。

6.4.2 操作共享内存的相关函数

【引用头】

#include 
#include 

【shmget函数】
注意,先创建的内存段的数据都会被初始化为0

int shmget (key_t key, size_t size, int shmflg);
//成功返回共享内存引用的ID(后面都用这个值),失败返回-1并设置错误号
  • key:key_t类型是一个整型,通过这个找到或者创建一个共享内存。一般使用16进制的非0值表示(实际写十进制也ok,会自动转换)(这个也是其他进程找到该共享内存的关键
  • size:要创建多大的共享内存(以页为单位)
  • shmflg:共享内存的属性
    • 访问权限
    • 附加属性:创建 还是 判断共享内存是不是存在
      • 创建: IPC_CREAT
      • 判断:IPC_CREAT | IPC_EXCL

【shmat函数】

void *shmat(int shmid,const void *shmaddr, int shmflg);
//成功则返回共享内存的首地址,失败返回(void*) -1
  • shmid:共享内存的标识(ID),由shmget返回值获取
  • shmaddr:申请的共享内存的起始地址,常指定NULL,由内核指定。
  • shmflg:对共享内存的操作
    • 读权限:SHM_RDONLY,必须要有读权限
    • 读写:0

【shmdt 函数】

int shmdt (const void *shmaddr) ;
//成功返回0,失败返回-1
  • shmaddr:共享内存的首地址

【shmctl函数】
对共享内存进行操作(常用于删除)

int shmctl(int shmid, int cmd,struct shmid_ds *buf);

  • shmid: 共享内存的ID
  • cmd:要做的操作
    • IPC_STAT:获取共享内存当前的状态
    • IPC_SET: 设置共享内存的状态
    • IPC_RMID: 标记共享内存被销毁(当关联都没了,自动删除)
  • buf:需要设置 或 获取共享内存的属性信息
    • IPC_STAT: buf存储数据
    • IPC_SET: buf中需要初始化数据,设置到内核中
    • IPC_RMID: 没有用,传递NULL即可

【ftok函数】
根据指定的路径名和int值,生成一个共享内存的key。(就是shmget中所需要的第一个参数,可以不自己写,用这个生成)

key_t ftok (const char *pathname, int proj_id);
  • pathname:指定一个存在的路径
  • proj_id:int类型的值,但是这个系统调用只会使用其中的1个字节(8个位,即范围0-255,一般指定一个字符 ‘a’)

Q & A

1、操作系统如何知道一块共享内存被多少个进程关联?
共享内存维护了一个结构体struct shmid_ds,这个结构体里面有个成员,其记录了关联的进程个数。
2、可不可以对共享内存进行多次删除
可以,因为shmctl只是标记删除,而不是直接删除。(其中,当共享内存被标记删除时,共享内存的key为0)
3、 共享内存和内存映射的区别

  • 共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)
  • 共享内存效果更高
  • 共享内存:所有的进程操作的是同一块共享内存;内存映射:每个进程在自己的虚拟地址空间中有一个独立的内存
  • 数据安全:进程突然退出时,共享内存还存在,但是内存映射区消失。运行进程的电脑突然死机了:共享内存没有了,内存映射区的数据由于磁盘文件的数据还在,因此内存映射区的数据还在。
  • 生命周期:内存映射区:进程退出,内存映射区销毁;共享内存:进程退出,共享内存还在,手动标记删除或者关机(如果一个进程退出,会自动取消与共享内存的关联)。

7 守护进程

7.1 概念

【会话的概念】
首先是进程,进程往外扩就是进程组,进程组再往外扩那就是遇到了会话。 其特点是:

  • 会话是一组进程组的集合。会话首进程是创建该新会话的进程,其进程ID会成为会话ID(即bash中的SID)。新进程会继承其父进程的会话ID。
  • 一个会话中的所有进程共享单个控制终端。按制终端会在会话首进程首次打开一个终端设备时被建立。一个终端最多可能会成为一个会话的控制终端
  • 在任一时刻,会话中的其中一个进程组会成为终端的前台进程组,其他进程组会成为后台进程组(bash命令最后加&,即可变为后台进程组)。只有前台进程组中的进程才能从控制终端中读取输入。当用户在控制终端中输入终端字符生成信号后,该信号会被发送到前台进程组中的所有成员。
  • 当控制终端的连接建立起来之后,会话首进程会成为该终端的控制进程。

【守护进程的概念】
守护进程(Daemon Process)是Linux中的后台服务进程。它是一个生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以d 结尾的名字。
其特点是:

  • 生命周期很长,守护进程会在系统启动的时候被创建并一直运行直至系统被关闭。它在后台运行并且不拥有控制终端。没有控制终端确保了内核永远不会为守护进程自动生成任何控制信号以及终端相关的信号(如SIGINT、SIGQUIT)
  • Linux的大多数服务器就是用守护进程实现的。

7.2 创建步骤

  1. 执行一个fork(),之后父进程退出(就是控制终端退出),子进程继续执行。
  2. 子进程调用setsid()开启一个新会话(这一步是为了脱离控制终端,用子进程是为了跟之前父进程的会话ID不冲突[毕竟子进程的进程ID是新的,创建新的进程组和会话ID都是新的,但是父进程已经是组长和会话长了])。
  3. 清除进程的umask 以确保当守护进程创建文件和目录时拥有所需的权限(常用umask(022))。
  4. 修改进程的当前工作目录,通常会改为根目录( / )【为了让他长时间运载,chdir(''/"),但是注意,有时候直接在根目录下是没有权限的,导致程序执行有误】 。
  5. 关闭守护进程从其父进程继承而来的所有打开着的文件描述符。
  6. 在关闭了文件描述符0、1、2之后,守护进程通常会打开/dev/null并使dup2()使所有这些描述符指向这个设备。
  7. 核心业务逻辑

上面的7步中,只有1、2、7是必须的。

8 引用

本笔记是针对牛客的高境老师的《第二章Linux多进程开发》内容书写的。默默感谢一下高老师,确实很细致。

你可能感兴趣的:(cpp项目开发,linux,c++,学习)