学自电子科技大学和黑马程序员,以下是一些补充内容和参考链接
C 语言中 void* 详解及应用 | 菜鸟教程 (runoob.com)
https://www.runoob.com/w3cnote/c-general-function.html
void 是什么?_杨博东的博客的博客-CSDN博客_void是什么
find /usr/include/ -name *.h | xargs grep 'MRT6_INIT'
IPC之信号量详解_Qiuoooooo的博客-CSDN博客_ipc信号量
相关概念
程序和进程
程序:是指编译好的二进制文件,在磁盘上,不占用系统资源(cpu、内存、打开的文件、设备、锁….)
进程:是一个抽象的概念,与操作系统原理联系紧密。进程是活跃的程序,占用系统资源。在内存中执行。当程序被操作系统装载到内存并分配给它一定资源后,此时可称为进程。
程序是静态概念,进程是动态概念。
虚拟内存
操作系统会为每个进程分配和维护虚拟内存地址
cpu 为什么要使用虚拟地址空间与物理地址空间映射?解决了什么样的问题?
- 方便编译器和操作系统安排程序的地址分布。程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。
- 方便进程之间隔离。不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程使用的物理内存。
- 方便OS使用你那可怜的内存。程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。
并发
并发,在操作系统中,一个时间段中有多个进程都处于已启动运行到运行完毕之间的状态。但,任一个时刻点上仍只有一个进程在运行。
例如,当下,我们使用计算机时可以边听音乐边聊天边上网。 若笼统的将他们均看做一个进程的话,为什么可以同时运行呢,因为并发。
单道程序设计
所有进程一个一个排队执行。若A阻塞,B只能等待,即使CPU处于空闲状态。而在人机交互时阻塞的出现时必然的。所有这种模型在系统资源利用上及其不合理,在计算机发展历史上存在不久,大部分便被淘汰了。
多道程序设计
在计算机内存中同时存放几道相互独立的程序,它们在管理程序控制之下,相互穿插的运行。多道程序设计必须有硬件基础作为保证。
时钟中断即为多道程序设计模型的理论基础。 并发时,任意进程在执行期间都不希望放弃cpu。因此系统需要一种强制让进程让出cpu资源的手段。时钟中断有硬件基础作为保障,对进程而言不可抗拒。 操作系统中的中断处理函数,来负责调度程序执行。
在多道程序设计模型中,多个进程轮流使用CPU (分时复用CPU资源)。而当下常见CPU为纳秒级,1秒可以执行大约10亿条指令。由于人眼的反应速度是毫秒级,所以看似同时在运行。
1s = 1000ms, 1ms = 1000us, 1us = 1000ns 1000000000
实质上,并发是宏观并行,微观串行!——-推动了计算机蓬勃发展,将人类引入了多媒体时代。
CPU和MMU
进程
进程控制块(PCB)
进程ID、用户ID、进程状态、调度信息、文件管理、虚拟内存管理、信号(进程间通信机制)、时间和定时器、……
task_struct结构:Linux进程控制块(PCB)的结构体
- 使用
grep -r "task_struct {" /usr/src
命令查找文件所在 - 所在文件:
/usr/src/linux-headers-5.4.0-26/include/linux/sched.h
部分结构如下:
1 | pid_t pid; // 进程id |
task_struct主要内容:
- 进程id。系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整数。
- 进程的状态,有就绪、运行、挂起、停止等状态。
- 进程切换时需要保存和恢复的一些CPU寄存器。
- 描述虚拟地址空间的信息。
- 描述控制终端的信息。
- 当前进程工作目录(Current Working Directory)。
- umask掩码。
- 文件描述符表,包含很多指向file结构体的指针。
- 和信号相关的信息。
- 用户id和组id。
- 会话(Session)和进程组。
- 进程可以使用的资源上限(Resource Limit)。
进程状态
volatile long state;
state成员的可能取值如下:
1
2
3
4
5
Linux进程的5种状态
在Linux系统中,一个进程被创建之后,在系统中可以有下面5种状态。进程的当前状态记录在进程控制块的state成员中。
就绪状态和运行状态(可执行状态)
就绪状态的状态标志state的值为TASK_RUNNING
。此时,程序已被挂入运行队列,处于准备运行状态。一旦获得处理器使用权,即可进入运行状态。
当进程获得处理器而运行时 ,state的
值仍然为TASK_RUNNING
,并不发生改变;但Linux会把一个专门用来指向当前运行任务的指针current
指向它,以表示它是一个正在运行的进程。
可中断等待状态
状态标志state
的值为TASK_INTERRUPTIBL
。此时,由于进程未获得它所申请的资源而处在等待状态。一旦资源有效或者有唤醒信号,进程会立即结束等待而进入就绪状态。
不可中断等待状态
状态标志state的值为TASK_UNINTERRUPTIBL
。此时,进程也处于等待资源状态。一旦资源有效,进程会立即进入就绪状态。这个等待状态与可中断等待状态的区别在于:处于TASK_UNINTERRUPTIBL
状态的进程不能被信号量或者中断所唤醒,只有当它申请的资源有效时才能被唤醒。
这个状态被应用在内核中某些场景中,比如当进程需要对磁盘进行读写,而此刻正在DMA中进行着数据到内存的拷贝,如果这时进程休眠被打断(比如强制退出信号)那么很可能会出现问题,所以这时进程就会处于不可被打断的状态下。
停止状态
状态标志state的值为TASK_STOPPED
。当进程收到一个SIGSTOP
信号后,就由运行状态进入停止状态,当受到一个SIGCONT
信号时,又会恢复运行状态。这种状态主要用于程序的调试,又被叫做“暂停状态”、“挂起状态”。
中止状态
状态标志state
的值为TASK_DEAD
。进程因某种原因而中止运行,进程占有的所有资源将被回收,除了task_struct
结构(以及少数资源)以外,并且系统对它不再予以理睬,所以这种状态也叫做“僵死状态”,进程成为僵尸进程。
进程状态切换
文件管理
struct fs_struct *fs; // 文件描述表
struct files_struct *files; // 文件表
内存管理
struct mm_struct *mm; // 内存
进程组织形式
进程控制块的物理组织结构
进程控制块的逻辑组织结构
使用pstree
查看当前进程的树形图
Linux中创建进程的方式:
- 在
shell
中执行命令或可执行文件- 由shell进程调用
fork
函数创建子进程
- 由shell进程调用
- 在代码中(已经存在的进程中)调用
fork
函数创建子进程- 通过
fork
函数创建的进程为已经存在进程的子进程
- 通过
Linux系统中进程0(PID=0)是由内核创建,其他所有进程都是由父进程调用fork
函数所创建的
Linux系统中进程0在创建子进程(PID=1,init
进程)后,进程0就转为交换进程或空闲进程
进程1(init进程)是系统中其他所有进程的共同祖先
进程属性
使用ps -aux
查看进程及其部分属性
用户和用户组
与进程相关联的用户ID包含以下类型:
真实用户与有效用户的关系:
- 通常情况下,有效用户与真实用户相同(有效用户ID等于真实用户ID ),有效用户组与真实用户组相同(有效用户组ID等于真实用户组ID)
- 可执行文件的文件属性可以设置特殊属性域,定义为“当执行此文件时,将进程的有效用户设置为文件的所有者”,与此类似,组ID也有类似的情况,定义为“当执行此文件时,将进程的有效用户组置为文件所有者所在组”。这两个标志位称为:设置用户ID位(setuid)和设置组ID位(setgid)
- 设置用户ID是图中11位,设置组ID位是图中10位
真实用户和真实用户组
进程真实用户为执行命令/可执行文件的用户,真实用户组为真实用户所在的组
有效用户和有效用户组
进程有效用户和有效用户组只有当可执行文件设置了setuid
位或setgid
位时才会发生变化
示例:passwd
命令程序就设置了设置用户ID位
使用chmod
对可执行文件修改设置用户ID位
普通用户能修改密码的原因
/etc/passwd
文件用来存储所有用户信息,/etc/shadow
用来存储用户密码- 所有用户都可以修改自己的密码(修改了
/etc/shadow
文件),但普通用户对/etc/shadow
没有读写权限 用户通过执行passwd命令(
/usr/bin/passwd
文件)来修改密码;该文件设置了setuid
位,在执行此命令时,该进程的有效用户不等于真实用户,而等于文件所有者(root)- Linux根据进程的有效用户进行权限检查,有效用户等于root则允许任何操作(包括对
/ect/shadow
文件的读写操作) - 如果清除掉
/usr/bin/passwd
文件的setuid
权限位,普通用户就不能修改自己的密码了
进程生命周期
C程序的启动函数是main
,也是进程代码的入口点
main ( int argc, char *argv[] );
当内核启动C程序时,会在调用main函数前调用特殊的启动函数来获取main
函数地址和传递给main
函数的参数,并且将这些信息填写到进程控制块中
进程的终止
正常终止
- 从
main
函数中返回 - 在任意代码中调用
exit
函数或_exit
函数 - 最后一个线程从其启动例程中返回
- 最后一个线程调用
pthread_exit
函数
异常终止
- 在任意代码中调用
abort
函数 - 接收到终止信号
exit/_exit函数
man 3 exit
,头文件stdlib.h
,函数定义:void exit( int status )
man 2 _exit
,头文件unistd.h
,函数定义:void _exit (int status )
- 调用这两个函数均会正常地终止一个进程
- 调用
_exit
函数将会立即返回内核 - 调用
exit
函数:- 执行预先注册的终止处理函数
- 执行文件I/O操作的善后工作,使得所有缓冲的输出数据被更新到相应的设备
- 返回内核
exit与return
return
是C语言关键字,exit
是POSIX API函数- 在
main
函数中,执行return
和调用exit
函数会产生相同的效果 - 在子函数中,执行
return
仅仅从子函数中返回,而调用exit
函数将会退出当前进程
终止处理函数
注册终止处理函数:
- 当进程终止时,程序可能需要进行一些自身的清理工作,如日志登记、资源释放等
- 通过
atexit
函数或on_exit
函数允许进程注册若干终止处理函数,当进程终止时,这些终止处理函数将会被自动调用
注意:
ANSI C
规定一个进程最多能注册32个终止处理函数- 当显示调用或者隐含调用
exit
函数(从main
中返回、最后一个线程退出等)终止进程将会回调这些注册的终止处理函数(最先注册的函数最后被回调) - 显示调用
_exit
函数终止进程时将不会回调这些注册的终止函数
atexit函数
man 3 atexit
头文件:#include <stdlib.h>
函数原型:int atexit(void (*function)(void));
- 返回值:成功0,失败非0
示例:atexit
1 | //atexit_text.c |
on_exit函数
man 3 on_exit
头文件:#include <stdlib.h>
函数原型:int on_exit(void (*function)(int, void *), void *arg);
func
第一个参数是来自最后一个exit()
函数调用中的status
或return
func
第二个参数是来自on_exit()
函数中的arg- 返回值:成功0,失败非0
示例:on_exit
1 | //on_exit_text.c |
进程环境
分类
1、内核空间 2、内存空间
用户空间布局
- 正文:CPU执行的代码部分,正文段通常是共享、只读的
- 初始化的数据:包含了程序中需明确赋初值的变量,如全局变量
int maxcount=99;
- 未初始化的数据:程序执行之前,将此段中的数据初始化为0,如全局变量
long sum[1000];
- 堆:用于动态分配内存
- 栈:主要用于支撑函数调用存放参数、局部变量等
- 命令行参数
- 环境变量
命令行参数
- 命令行参数:命令后面的字符都是参数
ls [参数] <路径或文件名>
,ls -l /home
mkdir [参数] <目录名>
,mkdir -p /home/xiaokun/src
cp [参数] <源文件路径> <目标文件路径>
,cp -r /usr/local/src /root
C程序中main函数参数:
1 | //myecho.C |
注意:命令名也传入main
中,占 0 位
环境变量
环境变量,是指在操作系统中用来指定操作系统运行环境的一些参数。通常具备以下特征:
① 字符串(本质) ② 有统一的格式:名=值[:值] ③ 值用来描述进程环境信息。
存储形式:与命令行参数类似。char *[]
数组,数组名environ,内部存储字符串,NULL
作为哨兵结尾。
使用形式:与命令行参数类似。
加载位置:与命令行参数类似。位于用户区,高于stack
的起始位置。
引入环境变量表:须声明环境变量。extern char ** environ;
1 | // 打印所有环境变量 |
环境变量表
- 每个进程都会有自己的环境变量表
- 通过全局的环境指针 (environ)可以直接访问环境变量表(字符串数组)
- 头文件
unistd.h
extern char **environ;
- 头文件
- 环境变量字符串形式为
name=value
name
是环境变量名称value
为环境变量赋值
常用环境变量
1)PATH
可执行文件的搜索路径。ls命令也是一个程序,执行它不需要提供完整的路径名/bin/ls,然而通常我们执行当前目录下的程序a.out却需要提供完整的路径名./a.out,这是因为PATH环境变量的值里面包含了ls命令所在的目录/bin,却不包含a.out所在的目录。PATH环境变量的值可以包含多个目录,用:号隔开。在Shell中用echo命令可以查看这个环境变量的值:
echo $PATH
2)SHELL
当前Shell,它的值通常是/bin/bash
3)TERM
当前终端类型,在图形界面终端下它的值通常是xterm,终端类型决定了一些程序的输出显示方式,比如图形界面终端可以显示汉字,而字符终端一般不行。
4)LANG
语言和locale,决定了字符编码以及时间、货币等信息的显示格式。
5)HOME
当前用户主目录的路径,很多程序需要在主目录下保存配置文件,使得每个用户在运行该程序时都有自己的一套配置。
父子进程
父子进程:
- 子进程是父进程的副本
- 子进程复制/拷贝父进程的PCB、数据空间(数据段、堆和栈)
- 父子进程共享正文段(只读)
- 子进程和父进程继续执行
fork
函数调用之后的代码 - 为了提高效率,fork后不并立即复制父进程数据段、堆和栈,采用了读时共享写时复制机制(Copy-On-Write)
- 当父子进程任意之一要修改数据段、堆、栈时,进行复制操作,并且仅复制修改区域
父子进程异同
父子相同处: 全局变量、.data、.text、栈、堆、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式…
父子不同处: 1.进程ID 2.fork返回值 3.父进程ID 4.进程运行时间 5.闹钟(定时器) 6.未决信号集
- 子进程的
tms_utime
,tms_stime
,tms_cutime
,tms_ustime
值被设置为 0
【重点】:父子进程共享:1. 文件描述符(打开文件的结构体) 2. mmap建立的映射区 (进程间通信详解)
父子进程共享文件
父子进程对共享文件的常见处理方式:
- 父进程等待子进程完成。当子进程终止后,文件当前位置已经得到了相应的更新
- 父子进程各自执行不同的程序段,各自关闭不需要的文件
进程相关函数
1)
获取环境变量:getenv
函数 或 environ
变量访问环境表
设置或修改环境变量:setenv
函数 或 putenv
删除环境变量:unsetenv
函数
2)
创建进程:fork
函数 或 vfork
函数
获取进程ID:getpid
函数
获取父进程ID:getppid
函数(和getpid
用法类似)
3)
头文件:
#include <unistd.h>
#include <sys/types.h>
返回值:执行成功返回对应ID,失败返回-1(错误原因存储在error中)
获取真实用户ID:uid_t getuid(void);
man 2 getuid
获取真实用户组ID:uid_t getgid(void);
获取有效用户ID:uid_t geteuid(void);
获取有效用户组ID:uid_t getegid(void);
getenv函数
man 3 getenv
getenv
函数用于获取环境变量值
头文件:stdlib.h
函数原型:char* getenv(const char *name);
name
指定环境变量名称,返回环境变量字符串指针,若未找到则返回NULL
1 |
|
setenv函数
man 3 setenv
作用:设置或修改环境变量值
头文件:stdlib.h
函数原型:int setenv(const char* name, const char* value, int overwrite);
- 如果
name
不存在,则添加name=value
到环境变量中 - 若
name
已经存在overwrite!=0
,修改原name
的值为value
overwrite=0
,不修改name
- 返回值:成功0,失败-1
unsetenv函数
man 3 unsetenv
头文件:stdlib.h
函数原型:int unsetenv(const char* name);
- 删除指定的环境变量字符串
- 返回值:成功0,失败-1
- 若
name
不存在,返回的也是0
putenv函数
man 3 putenv
putenv
函数将环境变量字符串放入环境变量表中;若该字符串已经存在,则覆盖
头文件:stdlib.h
函数原型:int putenv(char *string);
string
格式:name=value
- 添加宏定义:
_XOPEN_SOURCE
示例代码1
getenv
setenv
unsetenv
1 |
|
fork函数
man 2 fork
头文件:unistd.h
函数原型:pid_t fork(void);
返回值:
fork
函数被正确调用后,将会在子进程中和父进程中分别返回!!在子进程中返回值为0(不合法的PID,提示当前运行在子进程中)
在父进程中返回值为子进程ID(让父进程掌握所创建子进程的ID号)
- 出错返回
-1
fork用法:
- 父进程希望复制自己(共享代码,复制数据空间),但父子进程执行相同代码中的不同分支
- 网络服务程序中,父进程等待客户端的服务请求,当请求达到时,父进程调用fork创建子进程处理该请求,而父进程继续等待下一个服务请求到达
- 父子进程执行不同的可执行文件(父子进程具有完全不同的代码段和数据空间)
- 子进程从
fork
返回后,立即调用exec
类函数执行另外一个可执行文件
- 子进程从
示例:
1 |
|
示例2:创建5个子进程
1 |
|
vfork函数
man 2 vfork
头文件:unistd.h
函数原型:pid_t vfork(void);
- 返回值:成功返回pid,失败返回-1
vfork用法:
vfork
用于创建新进程,而该新进程的目的是执行一个可执行文件- 由于新程序将有自己的地址空间,因此
vfork
函数并不将父进程的地址空间完全复制到子进程中 - 子进程在调用
exec
或exit
之前,在父进程的地址空间中运行 vfork
函数保证子进程先执行,在它调用exec
或者exit
之后,父进程才会继续被调度执行(父进程处于TASK_UNINTERRUPTIBLE
状态),如果在
调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁
和fork相比:
fork
之后,子进程拷贝父进程的数据段,代码段。(读时共享写时复制)vfork
共享父进程的数据段。主要适用于执行可执行文件
示例:
1 |
|
getpid函数
man 2 getpid
作用:获取进程ID
头文件:
#include <sys/types.h>
#include <unistd.h>
函数:pid_t getpid( void);
pid_t
实际就是int
类型- 执行成功返回当前进程的ID,失败返回-1(错误原因存储在errno中)
示例代码2
getuid
getgid
geteuid
getegid
1 | //suid.c |
exec函数族
进程调用exec系列函数在进程中加载执行另外一个可执行文件
exec系列函数替换了当前进程(执行该函数的进程)的正文段、数据段、堆和栈(来源于加载的可执行文件)
执行exec系列函数后从加载可执行文件的main函数开始重新执行
exec系列函数并不创建新进程,所以在调用exec系列函数后其进程ID并未改变,已经打开的文件描述符不变
execl execle execlp execv execve execvp
共6个函数
man 3 execl
1 |
|
l
:list
,每个命令行参数都说明为一个单独的参数v
:vector
,命令行参数放在数组中e
:environment
,表示由函数调用者提供环境变量表p
:path
,使用环境变量数组,不使用进程原有的环境变量,设置新加载程序运行的环境变量
exec函数一旦调用成功即执行新的程序,不返回。只有失败才返回,错误值-1。所以通常我们直接在exec函数调用后直接调用perror()和exit(),无需if判断。
只有execve是真正的系统调用,其它五个函数最终都调用execve,所以execve在man手册第2节,其它函数在man手册第3节。
exec函数的关系
execl函数
函数原型:int execl(const char *pathname, const char *arg0, ...,NULL
pathname
:要执行程序的绝对路径名- 可变参数:要执行程序的命令行参数,以空指针
NULL
结束 - 返回值:出错返回-1,成功该函数不返回!
示例:
1 |
|
execv函数
函数原型:int execv(const char *pathname, char *const argv[]);
pathname
:要执行程序的绝对路径名argv
:数组指针维护的程序命令行参数列表,该数组的最后一个成员必须为空指针返回值:出错返回-1,成功该函数不返回!
示例:
1 |
|
execle函数
函数原型:int execle(const char *pathname, const char *arg0,... NULL, char *const envp[]);
pathname
:要执行程序的绝对路径名- 可变参数:要执行程序的命令行参数,以空指针结束
envp
指向环境字符串指针数组的指针,该数组的最后一个成员必须为空指针- 返回值:出错返回-1,成功该函数不返回
1 |
|
execlp函数
int execlp(const char *filename,const char *arg0, ...,NULL);
filename
参数可以是相对路径(路径信息从环境变量PATH中获取)- 例如默认环境变量中包含的
PATH=/bin:/usr/bin:/usr/local/bin/
1 |
|
示例
将ps aux
的结果输出到ps.out
文件中
1)法一:使用dup2
,dup2(fd, STDOUT_FILENO);
让标准输出指向ps.out
2)法二:使用输出重定向ps aux > ps.out
其中>
需要转义
1 |
|
子进程回收
孤儿进程
孤儿进程: 父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为init进程,称为init进程领养孤儿进程。
1 |
|
僵尸进程
父进程先于子进程结束,未回收子进程资源。子进程在退出之前会释放进程用户空间的所有资源,但PCB等内核空间资源不会被释放。
- 当父进程调用
wait
或waitpid
函数后,内核将根据情况关闭该进程打开的所有文件,释放PCB(释放内核空间资源) - 对于已经终止但父进程尚未对其调用
wait
或waitpid
函数的进程(TASK_ZOMBIE
状态),称为僵尸进程
如果父进程在子进程终止之前终止,则子进程的父进程将变为init
进程,保证每个进程都有父进程,由init
进程调用wait
函数进行善后
回收资源
一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait
或waitpid
获取这些信息,然后彻底清除掉这个进程。我们知道一个进程的退出状态可以在Shell中用特殊变量$?
查看,因为Shell是它的父进程,当它终止时Shell调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程。
获知子进程状态信息改变:
当一个进程发生特定的状态变化(进程终止、暂停以及恢复)时,内核向其父进程发送SIGCHLD
信号
父进程可以选择忽略该信号,也可以对信号进行处理(默认处理方式为忽略该信号)
wait
或waitpid
函数可以用于等待子进程状态信息改变,并获取其状态信息
wait函数
man 2 wait
功能:获取任意子进程的状态改变信息
- 若是终止状态,则回收子进程残留资源
- 阻塞等待子进程退出
- 获取子进程结束状态(退出原因)
头文件:sys/wait.h
函数原型:pid_t wait(int *wstatus);
wstatus
:用于获取子进程的状态改变wstatus
可以为空指针,此时父进程不需要具体了解子进程的状态变化,只是为了防止子进程成为僵尸进程,或者因为同步原因需等待子进程终止- 若
wstatus
不是空指针,则内核将子进程状态改变信息存放在它指向的存储空间中(正常终止→退出值;异常终止→终止信号) - 返回值:若成功返回状态信息改变子进程ID,出错返回
-1
wait函数传出参数status
子进程状态改变信息包含了多种类型的信息,可以通过系统提供的宏来快速解析子进程的状态。可使用来保存进程的退出状态。借助宏函数来进一步判断进程终止的具体原因。宏函数可分为如下三组:
宏 | 功能说明 |
---|---|
WIFEXITED(wstatus) |
当子进程正常终止时该宏为真,对于这种情况可进一步执行WEXITSTATUS(wstatus) ,获取子进程传递给exit 、_exit 函数参数的低8位或return 值 |
WIFSIGNALED(wstatus) |
当子进程异常终止(信号)时该宏为真,对于这种情况可进一步执行WTERMSTG(wstatus) ,获取使子进程终止的信号编号 |
WIFSTOPPED(wstatus) |
当子进程暂停时该宏为真,对于这种情况可进一步执行WSTOPSIG(wstatus) ,获取使子进程暂停的信号编号 |
WIFCONTINUED(wstatus) |
若子进程在暂停后已经继续运行则该宏为真 |
1 |
|
调用wait
函数之后,父进程可能出现的情况:
- 如果所有子进程都还在运行,则父进程被阻塞(
TASK_INTERRUPTIBLE
状态),直到有一个子进程终止或暂停,wait
函数才返回 - 如果已经有子进程进入终止或暂停状态,则
wait
函数会立即返回 - 若进程没有任何子进程,则立即出错返回
-1
等待特定子进程
如果一个进程有几个子进程,那么只要有一个子进程状态改变,wait函数就返回
如何才能使用wait函数等待某个特定子进程的状态改变?
调用
wait
,然后将其返回的进程ID和所期望的子进程ID进行比较。- 如果ID不一致,则保存该ID,并循环调用
wait
函数,直到等到所期望的子进程ID为止
- 如果ID不一致,则保存该ID,并循环调用
使用
waitpid
waitpid函数
man 2 waitpid
功能:等待某个特定子进程状态改变
头文件:sys/wait.h
函数原型:pid_t waitpid(pid_t pid, int *statloc, int options);
pid
pid = -1
:等待任意子进程执行终止(同wait)pid > 0
:等待指定ID为pid
的子进程终止pid = 0
:等待其组ID等于调用进程组ID的任意子进程pid < -1
:等待其组ID等于pid绝对值的任意子进程
statloc
:存放子进程终止状态options
:可以为0(阻塞),也可以是以下常量或常量的或WCONTINUED
:如果暂停的子进程由于被SIGCONT
唤醒而产生的SIGCHLD
,则函数将返回WUNTRACED
:如果有处于终止状态的进程,则函数返回WNOHANG
:非阻塞。如果没有任何已经终止的子进程则马上返回,,函数不等待,此时返回值为0
返回值:成功返回终止子进程ID,失败返回-1(无子进程)
waitpid的特有功能:
waitpid
可等待一个特定的进程的状态改变信息waitpid
可以实现非阻塞(异步)的等待操作,有时希望取得子进程的状态改变信息,但不希望阻塞等待子进程状态改变waitpid
支持作业控制(进程组控制)
示例:
1 |
|
进程通信
Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,Inter-Process Communication)。
在进程间完成数据传递需要借助操作系统提供特殊的方法,如:文件、管道、信号、共享内存、消息队列、套接字、命名管道等。随着计算机的蓬勃发展,一些方法由于自身设计缺陷被淘汰或者弃用。现今常用的进程间通信方式有:
① 管道(使用最简单)
② 信号(开销最小)
③ 共享映射区(无血缘关系)
④ 本地套接字(最稳定)
管道
管道是一种最古老、最简单的UNIX进程间通信机制,作用于有血缘关系的进程之间,完成数据传递。调用pipe系统函数即可创建一个管道。有如下特质:
1. 其本质是一个伪文件(实为内核缓冲区)
- 由两个文件描述符引用,一个表示读端,一个表示写端。
3. 规定数据从管道的写端流入管道,从读端流出。
管道的原理:管道实为内核使用环形队列机制,借助内核缓冲区(4k)实现。
ulimit -a
可以看到默认管道大小为$8\times 512b = 4k$ (8个扇区)
管道的局限性:
① 数据自己读不能自己写,自己写不能自己读。
② 数据一旦被读走,便不在管道中存在,不可反复读取。
③ 由于管道采用半双工通信方式。因此,数据只能单向流动。
④ 只能在有公共祖先的进程间使用管道。
常见的通信方式有,单工通信、半双工通信、全双工通信。
pipe函数
man 2 pipe
头文件:unistd.h
函数原型:int pipe(int pipefd[2]);
- 程序通过文件描述符
pipefd[0]
和pipefd[1]
来访问管道 pipefd[0]
管道读操作,pipefd[1]
管道写操作- 写入
pipefd[1]
的数据可以按照先进先出的顺序从pipefd[0]
中读出 - 返回值:成功返回
0
,出错返回-1
- 文件描述符
pipefd[0]
和pipefd[1]
无需open
,但需手动close
- 向管道文件读写数据其实是在读写内核缓冲区
管道创建成功以后,创建该管道的进程(父进程)同时掌握着管道的读端和写端。如何实现父子进程间通信呢?通常可以采用如下步骤:
1 |
|
管道读写行为
使用管道需要注意以下4种特殊情况(假设都是阻塞I/O操作,没有设置O_NONBLOCK
标志):
如果所有指向管道写端的文件描述符都关闭了(管道写端引用计数为0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回0,就像读到文件末尾一样。
如果有指向管道写端的文件描述符没关闭(管道写端引用计数大于0),而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次read会阻塞,直到管道中有数据可读了才读取数据并返回。
如果所有指向管道读端的文件描述符都关闭了(管道读端引用计数为0),这时有进程向管道的写端write,那么该进程会收到信号SIGPIPE,通常会导致进程异常终止。当然也可以对
SIGPIPE
信号实施捕捉,不终止进程。具体方法信号章节详细介绍。如果有指向管道读端的文件描述符没关闭(管道读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再次write会阻塞,直到管道中有空位置了才写入数据并返回。
总结:
1)读管道:
- 管道中有数据,read返回实际读到的字节数
- 管道中无数据:
- 管道写端被全部关闭,read返回0(类似读到文件结尾)
- 写端没有全部被关闭,read阻塞等待(不久的将来可能有数据递达,此时会让出cpu)
2)写管道
- 管道读端全部被关闭, 进程异常终止(也可使用捕捉SIGPIPE信号,使进程不终止)
- 管道读端没有全部关闭:
- 管道已满,write阻塞。(管道会自动扩容)
- 管道未满,write将数据写入,并返回实际写入的字节数。
管道缓冲区大小
可以使用ulimit –a
命令来查看当前系统中创建管道文件所对应的内核缓冲区大小。通常为:pipe size (512 bytes, -p) 8
也可以使用fpathconf
函数,借助参数选项来查看。使用该宏应引入
头文件<unistd.h>
函数原型:long fpathconf(int fd, int name);
- 成功:返回管道的大小 失败:-1,设置errno
name
是一些宏,如_PC_PIPE_BUF
管道小结
优点:简单,相比信号,套接字实现进程间通信,简单很多。
缺点:1)只能单向通信,双向通信需建立两个管道。2)只能用于父子、兄弟进程(有共同祖先)间通信。该问题后来使用fifo有名管道解决。
允许:
代码示例
练习:使用管道实现兄弟进程间通信,实现ls | wc -l
。兄:ls
弟: wc -l
父:等待回收子进程。
要求,使用“循环创建N个子进程”模型创建兄弟进程,使用循环因子i标示。注意管道读写行为。
1 |
|
命名管道
概念
FIFO常被称为命名管道,以区分管道(pipe)。
FIFO是Linux基础文件类型中的一种。但,FIFO文件在磁盘上没有数据块,仅仅用来标识内核中一条通道。各进程可以打开这个文件进行read/write,实际上是在读写内核通道,这样就实现了进程间通信。
- 管道只能在父子进程之间使用
- 通过FIFO,不相关的进程也能交换数据
- FIFO也被称为命名管道, FIFO是一种特殊的文件(创建FIFO类似于创建文件,FIFO的路径名存在于文件系统中)
- 创建FIFO之后可以通过文件I/O对其进行操作
- 非父子进程可以通过文件名来使用FIFO
创建FIFO的命令:mkfifo
,函数mkfifo
mkfifo函数
作用:创建命名管道
man 3 mkfifo
头文件:
sys/types.h
sys/stat.h
函数原型:int mkfifo(const char *pathname, mode_t mode) ;
pathname
:文件名(绝对路径)mode
:文件类型、权限等,如664
返回值:成功返回0
,出错返回-1
代码示例
fifo_r.c
读,fifo_w.c
写,提前创建命名管道fifo_t
1 | // fifo_r.c |
1 | // fifo_w.c |
fifo的open阻塞
关于fifo的open阻塞问题_Mr_John_Liang的博客-CSDN博客_open 阻塞模式
文件通信
使用文件也可以完成IPC,理论依据是,fork后,父子进程共享文件描述符。也就共享打开的文件。
无血缘关系的进程可以打开同一个文件进行通信。
父子进程示例
1 | /* |
无关系进程示例
1 | /* |
1 | /* |
存储映射I/O
存储映射I/O (Memory-mapped I/O) 使一个磁盘文件与存储空间中的一个缓冲区相映射。于是当从缓冲区中取数据,就相当于读文件中的相应字节。于此类似,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可在不适用read和write函数的情况下,使用地址(指针)完成I/O操作。
使用这种方法,首先应通知内核,将一个指定文件映射到存储区域中。这个映射工作可以通过mmap函数来实现。
mmap函数
man 2 mmap
头文件:#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
addr
:指定映射区的首地址。通常为NULL(Linux内核指定)length
:共享内存映射区的大小。(<= 文件的实际大小)prot
:共享内存映射区的读写属性。PROT_WRITE
、PROT_READ
flags
:共享内存的共享属性。MAP_SHARED
:会将映射区所做的操作反映到物理设备(磁盘)上MAP_PRIVATE
:映射区所做的修改不会反映到物理设备。
fd
:用于创建共享内存映射区的文件描述符offset
:偏移量,默认0。4k
的整数倍- 返回值:
- 成功:映射区的首地址
- 失败:返回宏
MAP_FAILED
即((void*)-1)
1 |
|
munmap函数
man 2 munmap
同malloc函数申请内存空间类似的,mmap建立的映射区在使用结束后也应调用类似free的函数来释放。
头文件:#include <sys/mman.h>
函数:int munmap(void *addr, size_t length);
addr
:映射区起始地址length
:映射区长度- 返回值:成功:0; 失败:-1
mmap注意事项
思考:
可以open的时候O_CREAT一个新文件来创建映射区吗?
如果open时O_RDONLY, mmap时PROT参数指定PROT_READ|PROT_WRITE会怎样?
文件描述符先关闭,对mmap映射有没有影响?
如果文件偏移量为1000会怎样?
对mem越界操作会怎样?
如果mem++,munmap可否成功?
mmap什么情况下会调用失败?
如果不检测mmap的返回值,会怎样?
总结:使用mmap时务必注意以下事项:
创建映射区的过程中,隐含着一次对映射文件的读操作。
当
MAP_SHARED
时,要求:映射区的权限应 <=文件打开的权限(出于对映射区的保护)。而MAP_PRIVATE
则无所谓,因为mmap中的权限是对内存的限制。映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭。
特别注意,当映射文件大小为0时,不能创建映射区。所以:用于映射的文件必须要有实际大小!!mmap使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。
munmap传入的地址一定是mmap的返回地址。坚决杜绝指针++、—操作。
如果文件偏移量必须为4K的整数倍
mmap创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。
父子进程通信
父子等有血缘关系的进程之间也可以通过mmap建立的映射区来完成数据通信。但相应的要在创建映射区的时候指定对应的标志位参数flags:
MAP_PRIVATE
: (私有映射) 父子进程各自独占映射区;
MAP_SHARED
: (共享映射) 父子进程共享映射区;
结论:父子进程共享:1. 打开的文件 2. mmap建立的映射区(但必须要使用MAP_SHARED)
练习:父进程创建映射区,然后fork子进程,子进程修改映射区内容,而后,父进程读取映射区内容,查验是否共享。
1 |
|
匿名映射
通过使用我们发现,使用映射区来完成文件读写操作十分方便,父子进程间通信也较容易。但缺陷是,每次创建映射区一定要依赖一个文件才能实现。通常为了建立映射区要open一个temp文件,创建好了再unlink、close掉,比较麻烦。 可以直接使用匿名映射来代替。其实Linux系统给我们提供了创建匿名映射区的方法,无需依赖一个文件即可创建映射区。同样需要借助标志位参数flags来指定。
1)使用MAP_ANONYMOUS
(或MAP_ANON
),不需要打开文件,如:
1 | int *p = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); |
2)需注意的是,MAP_ANONYMOUS
和MAP_ANON
这两个宏是Linux操作系统特有的宏。在类Unix系统中如无该宏定义,可使用如下两步来完成匿名映射区的建立。
1 | fd = open("/dev/zero", O_RDWR); |
法一映射示例:
1 |
|
法二映射示例:
1 |
|
无关系进程通信
实质上mmap是内核借助文件帮我们创建了一个映射区,多个进程之间利用该映射区完成数据传递。由于内核空间多进程共享,因此无血缘关系的进程间也可以使用mmap来完成通信。只要设置相应的标志位参数flags即可。若想实现共享,当然应该使用MAP_SHARED
了。
示例:借助文件file_shared
,mmap_r.c
读映射区,mmap_w.c
写映射区
1 | // mmap_r.c |
1 | // mmap_w.c |
启动mmap_w
后,再运行mmap_r
XSI IPC机制
进程通信机制
System V(“系统五”)系统上发明了三种IPC机制(消息队列、信号量和共享内存),通常称为System V IPC
。又因为后来被收录到Unix的XSI标准之中故又称为XSI IPC
。所以当你看到System V IPC 和 XSI IPC的时候实际上指的是同一种东西。
- 信号量集(semaphore set),用于实现进程之间的同步与互斥
- 共享内存(shared memory),用于在进程之间高效地共享数据,适用于数据量大,速度要求高的场景
- 消息队列(message queue),进程之间传递数据的一种简单方法
IPC对象ipcs
ipcs
命令可以查看IPC对象
ipcmk
:创建IPC对象
ipcrm
:删除IPC对象
IPC对象的key值和ID
Linux系统中的IPC对象都是全局的,为每个IPC对象分配唯一的ID
在IPC操作中通信各方需要通过ID来指示操作的IPC对象,需要有机制让通信各方获取获取IPC对象的ID
- 创建IPC对象的进程通过创建IPC对象函数的返回值可获取ID值
- 未创建IPC对象的进程如何获取IPC对象的ID值并使用该对象呢?
IPC机制的ID值为动态分配,无法提前约定,不能跨进程传递
多个进程提前约定使用相同的key值做为参数来创建IPC对象或打开已经创建的IPC对象
如果通信各方(进程)在创建/打开IPC对象时使用相同的key值:
- 首次使用该key值创建IPC对象的进程将真正创建该IPC对象,并获取其ID值
- 后续使用该key值创建IPC对象的进程都将在内核中找到该IPC对象并打开它,从而获取其ID值
IPC对象与key值一一对应,因此key值不能重复
ftok函数
通过ftok
函数来产生独特的key值,避免重复
man 3 ftok
1 |
|
pathname
是指定的文件名,可以是特殊文件也可以是目录文件)proj_id
是子序号- 成功返回key值,失败-1
如果要确保key_t
值不变,需要确保ftok
所指定的文件名不被删除
原因:每个文件都一个索引节点inode号,可以使用ls -i
命令查看,且inode
号一定不同。ftok
的一般实现方法是proj_id+inode
假设a.txt
文件节点号为65538
,换算成16进制0x010002
。指定proj_id=38
即0x26
,则key=0x2610002
函数对比
信号量集 | 共享内存 | 消息队列 | 功能 |
---|---|---|---|
semget |
shmget |
msgget |
创建或打开一个IPC对象,获得对IPC机制的访问权 |
semop |
shmat shmdt |
msgsnd msgrcv |
IPC操作: 信号量操作;连接/释放共享内存;发送/接收消息; |
semctl |
shmctl |
msgctl |
IPC控制:获得/修改IPC对象状态,“删除”IPC对象等 |
消息队列
- 进程之间传递数据的一种简单方法
- 把每个消息看作一个记录,具有特定的格式
- 消息队列就是消息的链表
- 写权限:按照一定的规则添加新消息
- 读权限:从消息队列中读走消息
- 消息队列能够克服管道或命名管道机制的一些缺点,例如实时性差等
消息队列结构
消息队列操作
头文件:
1 |
- 打开或创建消息队列对象:
msgget
- 向消息队列发送消息:
msgsnd
- 从消息队列接收消息:
msgrcv
- 消息队列控制操作:
msgctl
msgget函数
创建消息队列 man 2 msgget
int msgget(key_t key, int msgflg);
返回值:成功返回创建或打开的消息队列对象ID;出错返回-1
key
:创建或打开消息队列对象时指定的key值(提前约定或通过ftok
函数创建)- 若key为
IPC_PRIVATE
,则只能用于父子进程间通信
- 若key为
msgflg
:设置访问权限,取值可以为以下一个或多个值的或IPC_CREAT
:如果消息队列对象不存在则创建,否则打开已经存在的消息队列对象,创建还需同文件一样设置权限,如IPC_CREAT | 0664
IPC_EXCL
:只有消息队列对象不存在的时候,才能创建新的消息队列对象,否则就产生错误EEXIST
成功返回消息队列ID,失败返回-1
msgsnd函数
发送信息到消息队列 man 2 msgsnd
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
msgid
:消息队列IDmsgp
:指向mymsg
的结构体(mymsg
自己定义,样式如下)指针1
2
3
4struct mymsg {
long mtype; // 消息类型 >0,小于0的消息类型有特殊的指示作用
char mtext[]; // 消息内容, 数组长度自定义 >=0 也可以是结构体
};msgsz
:以字节为单位指定待发送消息的长度(mymsg
结构体中消息类型mtext
自定义数据的大小)msgflag
:可以是0
,也可以是IPC_NOWAIT
(该标志可以使函数工作在非阻塞模式)返回值:成功返回0;出错返回
-1
出现以下情况时:
1)指定的消息队列容量已满
2)在系统范围存在太多的消息
- 若设置了
IPC_NOWAIT
,则msgsnd
立即返回(返回EAGAIN
错误) - 若未指定该标志,则
msgsnd
导致调用进程阻塞,直到可以发送成功为止
msgrcv函数
从消息队列接收信息 man 2 msgrcv
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
返回值:成功返回实际读取数据的字节数;出错返回-1
- 前三个参数同
msgsnd
函数 msgtyp
:指定期望从消息队列中接收什么样的消息msgtyp = 0
:队列中第一个消息(消息队列是一个FIFO链表,所以返回的是队列中最早的消息)msgtyp > 0
:消息队列中类型值为msgtyp的第一个消息msgtyp < 0
:消息队列中类型值小于或等于msgtyp绝对值中类型值最小的第一个消息
msgflg
:当消息队列中没有期望接收的消息时会如何操作- 若设置了
IPC_NOWAIT
标志,则函数立即返回ENOMSG
错误 - 若未设置
IPC_NOWAIT
标志,否则msgrcv
导致调用进程阻塞直到如下某个事件发生:- 有其他进程向消息队列中发送了所期望接收的消息
- 该消息队列被删除,此时返回
EIDRM
错误 - 进程被某个信号中断,此时返回
EINTR
错误
- 若设置了
msgctl函数
获取、设置消息队列属性,删除消息队列 man 2 msgctl
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
cmd
:宏,决定对消息队列的操作IPC_STAT
:复制内核中的msqid_ds
至buf
中IPC_SET
:通过buf
设置内核中的msqid_ds
内容IPC_RMID
:删除消息队列,buf
设NULL
1 | struct msqid_ds { |
还有一大堆看不懂的操作
代码示例
一个发送方,一个接收方。发送方从键盘输入消息
消息发送者: msgsnd.c
1 |
|
消息接收者:msgrcv.c
1 |
|
信号量集
任务资源共享情况
临界资源:在一段时间内只允许一个任务访问的资源。诸任务间应采取互斥方式,实现对资源的共享
共享资源:允许多个任务同时访问同一种资源的多个实例
信号量类型
信号量一般分为三种类型:
- 互斥信号量:任务之间互斥访问临界资源
- 计数信号量:任务之间竞争访问共享资源
- 二值信号量:任务之间的同步机制
信号量是操作系统提供的管理资源共享的有效手段
信号量作为操作系统核心代码执行,其地位高于任务(进程或线程),任务调度不能终止其运行
信号量的实现
信号量s
一般包含以下成员:
- 整数值
s.count
(实现资源计数) - 任务阻塞队列
s.queue
信号量操作:初始化、P操作、V操作
- 在进程初始化信号量将
s.count
指定为一个非负整数值,表示可用的共享资源实例总数 运行中
s.count
可为负值(其绝对值表示当前等待访问该共享资源的进程数)P操作
wait(s)
1
2
3
4
5
6--s.count;//表示申请一个资源;
if (s.count < 0)//表示没有空闲资源;
{
调用进程进入阻塞队列s.queue;
阻塞调用进程;
}V操作
signal(s)
1
2
3
4
5
6++s.count; //表示释放一个资源
if(s.count <= 0) //表示有进程处于阻塞状态
{
从等待队列s.queue中取出一个进程p;
进程P进入就绪队列;
}
信号量状态图
信号量集结构
1 | struct semid_ds |
1 | struct sem |
信号量集操作
头文件:
1 |
创建或打开信号量集对象:
semget
函数信号量集操作(信号量的PV操作):
semop
函数,semtimedop
函数信号量集控制(信号量初始化和删除操作):
semctl
函数
semget函数
man 2 semget
创建或打开信号量集对象
int semget(key_t key, int nsems, int semflg);
key
:用于创建或打开信号量集对象时指定的key
值(约定或通过ftok
函数创建)或者0(IPC_PRIVATE
)—— 创建一个只有创建进程可以访问的信号量。nsems
:信号量集对象中包含的信号量数量(例如取值为1,则信号量集只包含1个信号量)semflg
:设置访问权限,取值可以为以下某个值或多个值的或IPC_CREAT
:如果消息队列对象不存在则创建,否则打开已经存在的消息队列对象,创建还需同文件一样设置权限,如IPC_CREAT | 0664
IPC_EXCL
:只有消息队列对象不存在的时候,才能创建新的消息队列对象,否则就产生错误EEXIST
- 返回值:信号量集对象ID;失败返回
-1
;
semop函数
man 2 semop
信号量PV操作
1 | int semop(int semid, struct sembuf *sops, size_t nsops); |
semid
:信号量集对象ID(semget
的返回值)sops
:指向sembuf
结构体数组(ssembuf
自己定义,样式如下)的指针1
2
3
4
5
6
7
8struct sembuf {
unsigned short sem_num; //信号量序号,指示本次是操作信号量集中的哪个信号量(序号从0开始)
short sem_op; //信号量操作码
/*该值为正,信号量V操作,增加信号量的值(为n,则加n)
该值为负,信号量P操作,减小信号量的值(为-n,则减n)
该值为0,对信号量的当前值是否为0的测试*/
short sem_flg; // semop操作控制标志
}nsops
:第二个参数中sembuf
结构数组的元素个数成功返回0;失败返回-1
sem_flg
对semop
操作进行控制,主要有2个控制标志:
IPC_NOWAIT
- 当指定的PV操作不能完成时,进程不会被阻塞,
semop
函数立即返回。返回值为-1,errno
置为EAGAIN
。 - 例如:信号量值在P操作后小于0,如果操作控制标志没有设置
IPC_NOWAIT
,则将调用进程阻塞,semop
函数将不会返回直到资源可用为止;若设置了IPC_NOWAIT
,则semop
函数直接返回,调用进程将不会阻塞
- 当指定的PV操作不能完成时,进程不会被阻塞,
SEM_UNDO
- 进程异常退出时,执行信号量解除(undo)操作
- 例如:进程执行了P操作后异常退出,如果操作控制标志设置了
SEM_UNDO
,则内核会对该进程执行V操作,保证安全性
semctl函数
man semctl
信号量初始化和删除操作
int semctl(int semid, int semnum, int cmd, ...);
(变参,三个或四个)
semid
:信号量集对象的ID(semget
的返回值)semnum
:信号量集中信号量的编号(如果控制是针对整个信号量集,则将该值设置为0)cmd
:要执行的控制命令1
2
3
4
5
6
7
8
9
10// 针对 整个信号量集 的控制命令主要包括:
IPC_RMID //删除
IPC_SET //设置ipc_perm参数
IPC_STAT //获取ipc_perm参数
IPC_INFO //获取系统信息
// 针对信号量集中 某个信号量 的控制命令主要包括:
SETVAL //设置信号量的值(一般用于信号量初始化时设置初始值)
GETVAL //获取信号量的值
GETPID //获取信号量拥有者进程的PID值arg
:与控制命令配合的参数(可选)1
2
3
4
5
6union semun {
int val; /* Value for SETVAL */
struct semid_ds *buf; /*Buffer for IPC_STAT,IPC_SET */
unsigned short *array;/* Array for GETALL, SETALL */
struct seminfo *__buf; /* Buffer for IPC_INFO */
};返回值:成功返回值大于或等于0;失败返回值-1
semctl
函数的控制命令通常为以下两种情况:
SETVAL
:用来把信号量集中的某个信号量初始化为一个给定值, 这个值通过arg参数(union semun
中的val
成员)来指定IPC_RMID
:用于删除信号量集对象,此时arg
参数无需赋值GETVAL
:返回值就是get
得到的信号量的值
代码示例
父子进程
父子进程共享内存映射区的内容,使用信号量实现互斥
1 |
|
无关系进程
不同进程间创建相同信号量需要判定是否已存在,若已存在不能重新初始化
1 |
|
分别用两个终端运行./IPC_sem
,等待约二十秒查看结果,注意删除信号量之前睡眠15秒是为了等待另一进程完成PV操作,即删除信号量时需保证所有使用该信号量的进程完成PV操作,否则会出错
共享内存
- 共享内存是内核为进程间通信创建的特殊内存段
- 不同进程可以将同一段共享内存连接到自己的地址空间
- 最快的进程间通信方式
- 本身不具有互斥访问机制
共享内存操作
头文件:
1 |
- 打开或创建共享内存对象:
shmget
函数 将共享内存连接到进程空间:
shmat
函数断开进程空间和共享内存的连接:
shmdt
函数- 共享内存控制操作:
shmctl
函数
shmget函数
int shmget(key_t key, size_t size, int shmflag)
key
:创建或打开共享内存对象时指定的key值(提前约定或通过ftok函数创建)或者0(IPC_PRIVATE
)—— 创建一个只有创建进程可以访问的信号量。size
:指定创建的共享内存大小(首次创建共享内存对象时通过该参数指定共享内存段的大小)shmflag
:设置共享内存的访问权限 ,取值可以为以下一个或多个值的或1
2
3
4
//还有9 bits的权限 如 0644成功返回0,失败-1
shmat函数
void *shmat(int shmid, const void *shmaddr, int shmflg);
shmid
:共享内存对象IDshmaddr
:指明共享内存连接到的进程空间地址;通常指定为NULL
,让Linux系统决定共享内存连接到进程空间中的哪个地址shmflg
:可以设置以下两个标志位之一或者不设置(值为0)SHM_RND
( addr参数指定的地址应被规整到内存页面大小的整数倍)SHM_RDONLY
(共享内存连接到进程空间时被限制为只读)
- 成功返回共享内存在进程空间中的连接地址;失败返回 -1
shmdt函数
int shmdt(const void *shmaddr);
- 从调用进程的地址空间中,取消由
shmaddr
参数所指向的,共享内存映射区域 shmaddr
:共享内存在进程空间中的连接地址,一般为shmat
函数返回的地址。- 内核将该共享内存的加载计数减1
- 成功返回0;失败返回-1
shmctl函数
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
shmid
:共享内存对象IDcmd
:执行的控制命令IPC_RMID
,从系统中删除该共享内存对象IPC_STAT
,获取共享内存对象的内核结构值IPC_SET
,设置共享内存对象的内核结构值
buf
:指向shmid_ds
结构的指正,当控制命令为IPC_STAT
或IPC_SET
时,用于获取或设置共享内存对象的内核结构- 成功返回0;失败返回-1
生产者消费者示例
问题描述:进程之间通过共享缓冲池(包含一定数量的缓冲区)交换数据。其中,“生产者”进程不断写入,而“消费者”进程不断读出;任何时刻只能有一个任务可对共享缓冲池进行操作。
- 进程之间的共享缓冲池可以通过共享内存机制实现
消费者读取共享内存:consumer.c
1 |
|
生成者写入共享内存:producer.c
1 |
|
进程组
概念
进程组,也称之为作业。BSD于1980年前后向Unix中增加的一个新特性。代表一个或多个进程的集合。每个进程都属于一个进程组。在waitpid函数和kill函数的参数中都曾使用到。操作系统设计的进程组的概念,是为了简化对多个进程的管理。
进程组:每个进程都属于一个进程组,进程组是一个或多个进程集合,他们相互关联,共同完成一个实体任务,每个进程组都有一个进程组长,默认进程组ID与进程组长ID相同。
当父进程,创建子进程的时候,默认子进程与父进程属于同一进程组。进程组ID\==第一个进程ID(组长进程)。所以,组长进程标识:其进程组ID\==其进程ID
可以使用kill -SIGKILL -进程组ID
来将整个进程组内的进程全部杀死。
组长进程可以创建一个进程组,创建该进程组中的进程,然后终止。只要进程组中有一个进程存在,进程组就存在,与组长进程是否终止无关。
进程组生存期:进程组创建到最后一个进程离开(终止或转移到另一个进程组)。
一个进程可以为自己或子进程设置进程组ID
操作函数
man 2 setpgid
1 |
|
getpgrp函数
获取当前进程的进程组ID
getpgid函数
获取指定进程的进程组ID。如果pid = 0,那么该函数作用和getpgrp一样。
setpgid函数
改变进程默认所属的进程组。通常可用来加入一个现有的进程组或创建一个新进程组。
如改变子进程为新的组,应fork后,exec前。
权级问题。非root进程只能改变自己创建的子进程,或有权限操作的进程
示例
1 |
|
会话
创建会话
创建一个会话需要注意以下6点注意事项:
调用进程不能是进程组组长,该进程变成新会话首进程(session header)
该进程成为一个新进程组的组长进程。
需有root权限(ubuntu不需要)
新会话丢弃原有的控制终端,该会话没有控制终端
该调用进程是组长进程,则出错返回
建立新会话时,先调用fork,父进程终止,子进程调用
setsid
getsid函数
man 2 getsid
获取进程所属的会话ID
1 |
|
pid为0表示察看当前进程session ID
成功:返回调用进程的会话ID;失败:-1
ps ajx
命令查看系统中的进程。参数a表示不仅列当前用户的进程,也列出所有其他用户的进程,参数x表示不仅列有控制终端的进程,也列出所有无控制终端的进程,参数j表示列出与作业控制相关的信息。
组长进程不能成为新会话首进程,新会话首进程必定会成为组长进程。
setsid函数
man 2 setsid
创建一个会话,并以自己的ID设置进程组ID,同时也是新会话的ID。
1 |
|
成功:返回调用进程的会话ID;失败:-1
调用了setsid函数的进程,既是新的会长,也是新的组长。
示例
1 |
|
守护进程
Daemon(精灵)进程,是Linux中的后台服务进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以d
结尾的名字(如vsftpd
,httpd
,sshd
等)。
Linux后台的一些系统服务进程,没有控制终端,不能直接和用户交互。不受用户登录、注销的影响,一直在运行着,他们都是守护进程。如:预读入缓输出机制的实现;ftp服务器;nfs服务器等。
创建守护进程,最关键的一步是调用setsid函数创建一个新的Session,并成为Session Leader。
创建守护进程模型
- 创建子进程,父进程退出
fork()
所有工作在子进程中进行形式上脱离了控制终端
- 在子进程中创建新会话
setsid()
使子进程完全独立出来,脱离控制终端
- 改变当前目录为根目录
chdir()
防止占用可卸载的文件系统,也可以换成其它路径
- 重设文件权限掩码
umask()
防止继承的文件创建屏蔽字拒绝某些权限,增加守护进程灵活性
- 重定向文件描述符0/1/2 —>
/dev/null
dup2()
继承的打开文件不会用到,浪费系统资源,无法卸载
开始执行守护进程核心工作
守护进程退出处理程序模型
创建示例
daemon.c
1 |
|