系统编程
进程
前言
在 lilnux 系统的内核当中其多任务的架构有两大载体,分别为进程跟线程,其中我们把进程称之为资源的最小单位,把线程称之为调度的最小单位。资源的最小单位指的是系统在给应用程序划分资源的时候是以进程为单位划分的,而调度的最小单位指的是系统去调度指令时是以线程为单位去调度的。进程与线程他们是相辅相成,且不可缺失的,执行程序是会优先诞生进程,以进程为单位申请资源,并且运行起一条线程用于给处理器进行指令调度,在单个进程中可以允许创建多条线程。
进程特点:
1. 每一个进程独立一片属于自己的资源(虚拟内存资源,文件描述符资源等等)
1. 每一个进程的操作都是互不干扰的。
1. 系统申请独立的资源是以进程为单位。
应用场景:
1. 调度第三方程序
1. 启用服务
线程的定义:基于进程的基础上提出来的一种进程内部进行多任务的架构的任务调度形式,
CPU 以任务结构体节点作为调度的基础,而线程便是任务结构体本身。
线程特点:
- 线程是基于进程之上创建的。
- 一个进程中允许有多个线程。
- 线程共享同一进程的资源。
- CPU 调度指令以线程为单位。
线程应用场景:多任务的场景优先考虑线程。
一、进程基本API
基本概念:
程序:编译后产生的,格式为ELF(Executable and Linkable Format)的,存储于硬盘的文件。
进程:程序中的代码和数据,被加载到内存中运行的过程。
最开始的系统进程叫systemd,这个进程的诞生比较特别,其身份信息在系统启动前就已经存在于系统分区之中,在系统启动时直接复制到内存。
创建子进程
fork()函数
#include <unistd.h>
pid_t fork(void);
// 注意:pid_t是无符号整型重命名
函数功能:将当前的进程复制一份,然后这两个进程同时从本函数的下一语句开始执行。
返回值:该函数执行成功之后,将会产生一个新的子进程,在新的子进程中其返回值为 0,在原来的父进程中其返回值为大于0的正整数,该正整数就是子进程的 PID。
接口解析:
- 从fork()函数所在行开始,所有的代码、变量都会复制成两份。
- 该函数会返回两次,一次返回父进程,值是子进程的PID,一次返回子进程,值固定为0。
- 父子进程是并发执行的,没有先后次序,若要控制次序,要依赖于信号量、互斥锁、条件量等其他条件。
一个进程复刻一个子进程的时候,会将自身几乎所有的资源复制一份,具体如下
A) 实际UID和GID,以及有效UID和GID。
B) 所有环境变量。
C) 进程组ID和会话ID。
D) 当前工作路径。
E) 打开的文件。
F) 信号响应函数。
G) 整个内存空间,包括栈、堆、数据段、代码段、标准IO的缓冲区等等。
而以下属性,父子进程是不一样的:
A) 进程号PID。PID是身份证号码,哪怕亲如父子,也要区分开。
B) 记录锁。父进程对某文件加了把锁,子进程不会继承这把锁。
C) 挂起的信号。这是所谓“悬而未决”的信号,等待着进程的响应,子进程不会继承这些信号。
注意:fork函数产生子进程的规律:2n ,其中n为;总共调用了多少次fork函数
回收子进程
wait函数和waitpid函数
#include <sys/wait.h>
pid_t wait(int *wstatus);
// wstatus用于存储返回状态的信息,并不是用来存储函数exit(1)中的1这个整型值
// 1这个值仅是返回状态的信息的一部分
如果需要获取这些返回状态的信息,需要用到如下宏定义:
宏 | 功能 |
---|---|
WIFEXITED(status) | 判断子进程是否正常退出 |
WEXITSTATUS(status) | 获取正常退出的子进程的退出值 |
WIFSIGNALED(status) | 判断子进程是否被信号杀死 |
WTERMSIG(status) | 获取杀死子进程的信号的值 |
函数功能
- 阻塞当前进程。
- 等待其子进程退出并回收其系统资源;
接口解析:
- 如果当前进程没有子进程,则该函数立即返回。
- 如果当前进程有不止1个子进程,则该函数会回收第一个变成僵尸态的子进程的系统资源。
- 子进程的退出状态信息(包括退出值、终止信号等)将被放入wstatus所指示的内存中,若wstatus指针为NULL,则代表当前进程放弃其子进程的退出状态。
另一个回收僵尸资源的常用函数:
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *wstatus, int options);
与wait()的区别:
- 可以通过参数
pid
用来指定想要回收的子进程。 - 可以通过 options 来指定非阻塞等待。
- 接口解析与wait相同。
pid
和 options 这两个参数的取值和作用详见下表:
pid | 作用 | options | 作用 |
---|---|---|---|
<-1 | 等待组ID等于pid绝对值的进程组中的任意一个子进程 | 0 | 阻塞等待子进程的退出 |
-1 | 等待任意一个子进程 | WNOHANG | 若没有僵尸子进程,则函数立即返回 |
0 | 等待本进程所在的进程组中的任意一个子进程 | WUNTRACED | 当子进程暂停时函数返回 |
>0 | 等待指定pid的子进程 | WCONTINUED | 当子进程收到信号SIGCONT继续运行时函数返回 |
退出进程
#include <stdlib.h>
void exit(int status);
参数status:会将这个值(仅将这个值的最低8个比特位)后传输给等待回收这个进程的人(也就是调用 wait系列函数的人)。
函数功能:退出本调用这个函数的进程;退出之前先执行 atexit 或者是 on_exit 函数所注册过的退出处理函数群,然后再清楚所有标准 IO 缓冲区,再退出进程。
注意:仅使用exit函数或_exit函数而不使用wait或waitpid函数会造成僵尸进程。
#include <stdlib.h>
void _exit(int status);
函数功能:直接退出调用这个函数的进程,中间不经过任何操作,不会执行任何标准C库中的清理操作,如关闭文件描述符、刷新输出缓冲区等。注意:这是一个有安全隐患的函数。
函数参数与exit函数一致。
#include <stdlib.h>
int atexit(void (*function)(void));
函数功能:简化版本的退出处理函数,在程序调用 exit 函数或者是 main 函数 return 的时候去
执行这个函数所注册过的退出处理函数。
注意:按照后入先出的原则执行注册的退出处理函数,可以注册多个。
#include <stdlib.h>
int on_exit(void (*function)(int , void *), void *arg);
函数功能:复杂版本的退出处理函数,在程序调用 exit 函数或者是 main 函数 return 的时候去
执行这个函数所注册过的退出处理函数。
参数:
-
function:这是一个 void (*)(int,void *)函数指针,要求传入函数类型格式,并且将函数名放在这里即可,其中 int 的这个参数放的是 exit 或者是 main 函数 return 的数值(status 值),void *则是 on_exit 第二个参数。
-
arg:传输给 function 的第二个参数
注意:按照后入先出的原则执行注册的退出处理函数,可以注册多个。
进程的应用
1. 调用第三方程序
使用exec函数族
int execl(char *path,char *arg,...);
int execlp(char *file,char *arg,...);
int execle(const char *path, const char *arg,..., char *envp[])
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
函数参数:
l --》参数以列表的形式逐一列举
p --》表示从系统的环境变量中去查找要执行的shell命令或者程序
e --》表示在执行命令的时候,可以顺便设置环境变量允许改变子进程的环境变量,无后缀e时,子进程使用当前程序的环境
v --》参数需要使用指针数组存放
返回值:成功则没有返回值,因为调用这个函数的原本的程序会被覆盖掉,失败则返回-1,errno 会被设置。
2. 守护进程(精灵进程)
定义:让一个程序一旦运行便可以脱离开终端的控制,成为一个默默在后面跑起来的服务,我
们把它称之为守护进程,或者是精灵进程。
守护进程的相关概念:进程组、前台进程组、后台进程组、会话、控制终端。
系统调度的最小单位是进程(或称任务,task)若干进程可组成一个进程组,若干进程组可组成一个会话,可见这几个概念都只是进程的组织方式,之所以要构造进程组和会话,其根本目的是为了便于发送信号:我们可以通过给进程组或会话发送信号,便可使得其内所有的进程均可收到信号。
概念讲解
进程组
进程是系统中的活跃个体,是系统的调度单位,进程就像现实世界中的活动的人,社会为了便于管理,一般都会将人归总到某一个集体中,比如公司、学校、组织等,系统中的进程也一样,他们可以按照实际所需进入某个进程组。进程组的好处在于,可以给组内的所有进程统一发送信号。
#include <sys/types.h>
#include <unistd.h>
// 功能:将进程pid的所在进程组设置为pgid
// 如果pid == 0,则设置本进程
// 如果pgid == 0,等价于pgid == pid
// 注意:若进程pid与进程组pgid不在同一会话内,设置将失败
int setpgid(pid_t pid, pid_t pgid);
// 功能:获取进程pid所在进程组ID
pid_t getpgid(pid_t pid);
前台进程组
定义:一般而言,进程从终端启动时,会自动创建一个新进程组,并且该进程组内只包含这个创始进程,而其后代进程默认都将会被装载在该进程组内部,这个进程组被称为前台进程组。
特点:可以接收控制终端发来的信号。
在一个会话中,前台进程组只有一个,而后台进组可以有多个。
后台进程组:
可以在终端中加个 ‘&’ 来使得进程进入后台进程组。
让一个进程在后台进程组中运行,通常是为了使其让出控制终端,不接受控制终端的输入和信号,但这并不意味着它不受控制终端控制,控制终端依然可向其发送挂断信号。
会话:
会话(session)原本指的是一个登录过程中所产生的的所有进程组的总和,可以理解为一个登录窗口就是一个会话。但在伪终端中,每打开一个窗口实际上也是创建了一个会话。
会话可以理解为就是进程组的进程组,会话的作用可总结为:
- 可关联一个控制终端(比如键盘)设备,并从控制终端获得输入信息
- 可对会话内的所有进程组统一发送信号
- 可将控制终端的关闭动作,转换为触发挂断信号并发送给所有进程
当我们打开一个伪终端,或者打开一个远程登录工具输入账户密码的过程中,默认都调用了如下函数接口去创建一个新的会话。
注意:
- 进程组组长不能调用该函数。
- 新创建的会话没有关联控制终端,因此其内进程不受控制终端影响。
- 创建会话的进程,称为该会话的创始进程,创始进程有权捕获一个控制终端(在编写守护进程时通常需要避免),会话的其余成员进程无权获得控制终端。
控制终端
控制终端通常会关联一个输入设备,可以给前台进程组发送数据或信号,平常使用的许多信号快捷键,就是通过控制终端发送给前台进程组内的进程的。
守护进程编写步骤
额外知识点
因为在bash中只要判断其子进程退出了,它就立即输出命令行提示符,不管其孙子进程是否退出了。
bash的命令行信息穿插到父子进程中间的原因是父进程先于子进程退出,只要保证子进程先退出即可解决该问题,加上wait()函数可使得父进程必须要等到子进程退出之后才能往下运行,而子进程遇到wait()函数不会有任何效果直接穿过去。
vfork:
专门为了 exec 系列函数服务的一个创建进程的函数,功能跟参数与 fork 是一
模一样,只有一下操作是不一样的:
1, vfork 成功创建子进程,子进程跑起来,引用的内存是父进程的内存
2, vfork 成功创建子进程之后,父进程陷入睡眠
3, 当 vfork 创建出来的子进程调用了 exec 系列函数,去加载第三方程序的时候或者是子进程结束,父进程才会被唤醒
vfork 这样的操作是为了节约 fork 函数创建子进程而进行的内存拷贝这一环节的时间(vfork 子进程是不会拷贝父进程的虚拟内存的内容)。
二、管道文件
无名管道
由 linux 系统内部提供一种通信方式,在内核空间当中类似于开辟一片缓冲区,大
家可以往这片缓冲区里面去写入内容及读取内容。
#include <unistd.h>
int pipe(int pipefd[2]);
函数功能:
调用 pipe 函数,在成功的情况下便可以在 linux 的内部诞生一个无名管道主体(我们在软件层是看不到的),并且返回管道的读写两个端口的文件描述符,放进去 pipefd这个参数当中。pipefd:要求我们是一个整型的有两个元素的数组,成功调用的时候会将管道的读端文件描述符放到 0 号元素里面,将管道的写端文件描述符放到 1 号元素里面。
返回值:成功返回 0,失败返回-1,errno 会被设置。
无名管道文件的特点:
1,没有名字,因此无法使用 open( )。
2,只能用于亲缘进程间(比如父子进程、兄弟进程、祖孙进程……)通信。
3,半双工工作方式:读写端分开。
4,写入操作不具有原子性,因此只能用于一对一的简单通信情形。
5,不能使用 lseek( )来定位
6, 具备阻塞特性
有名管道
有名管道文件:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
函数功能:创建一个有名管道文件,注意一定是 linux 的兼容文件系统才能诞生的
参数:pathname:创建的管道文件的路径+名字;mode:文件权限
返回值:成功返回 0,失败返回-1
特点:
- 全双工的通信(在读的同时也能写)。
- 有名管道的适用范围更广,既能用于父子,兄弟进程之间通信,也能用于没有任何血缘关系进程间通信。
- 有名管道只能在纯粹的linux环境中创建, 不能再Windows和Linux的共享文件夹中创建。
- 进程1写入内容到管道中然后退出,运行进程2读取内容是读取不了的,有名管道只用于通信,不保存数据。
- 写入具备原子性。
- 具备阻塞特性。
- 不能 lseek 这个函数定位。
- 支持多路同时写入。
- 具名管道一旦没有任何读者和写者,系统判定管道处于空闲状态,会释放管道中的所有数据。
典型应用:向日志系统写入文件。
三、信号
1 异步信号
系统当中的信号分成两种:
1, 非实时信号
1-31 的信号值的信号,它们是从Unix系统继承下来的经典系统元素。
特点:
- 1,每一个非实时信号一般都对应着一个默认执行动作(缺省动作)
- 2,每一个非实时信号都有自己的名字
- 3,每一个非实时信号都有自己触发的系统事件
- 4,不排队,信号可以被嵌套执行
- 5,信号丢失(非实时信号的记录信号的方式是用一个标志位记录的)
- 6,在进程的挂起信号中,进程会优先响应实时信号。
2,实时信号
34-64 的信号值的信号共31个信号,它们是Linux系统新增的实时信号,也被称为“可靠信号”。
特点:
1, 每个信号都没有自己默认的执行动作。
2, 每一个实时信号不一定有自己的名字。
3, 信号可以被嵌套执行。
4, 信号不会丢失(实时信号记录信号是用一个计数变量来记录的)。
5,即使相同的实时信号被同时发送多次,也不会被丢弃,而会依次挨个响应。
几个常用的信号:
19 :SIGSTOP --》让进程暂停执行
18:SIGCONT --》让进程继续执行
2:SIGINT --》键盘输入ctrl+c默认就是给当前进程发送了SIGINT信号
9:SIGKILL --》把进程杀死
特例:系统当中有两个信号是就算设置了也会按照缺省动作执行动作,这两个信号不可以被忽略,不可以被阻塞。
SIGKILL:杀死进程
SIGSTOP:暂停进程
如何使用命令发送一个信号:
用来发送一个指定信号给某个进程:kill -s 发送的信号值 进程的 PID 号
用来发送一个指定信号给某个应用程序(如果有多个同名应用程序,他们都会接收到这个信号)
killall -s 发送的信号值 应用程序的名字
linux中信号四种响应方式:
1, 执行默认缺省的动作:每一个信号都有自己的默认缺省动作,如果你没有在进程中堆信号进行任何操作,进程收到对应的信号就会执行其默认的缺省动作。对应的宏定义:SIG_DFL
2, 捕捉信号,执行我们指定的动作
3, 忽略信号,把某个给直接忽略掉 对应的宏定义SIG_IGN
4, 阻塞信号,在阻塞的期间照样会接受信号,只是不会立即响应,而是将信号挂起,等到解开阻塞的时候才会响应
在新建进程中的注意事项:
1, 子进程会继承父进程的所有信号设置及操作
2, 被挂起的信号不会被继承过去
异步信号的相关函数:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数:
pid是接收信号的进程PID,称为目标进程。
sig是信号的编号。
函数功能:向指定进程发送特定信号。
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
函数功能:信号的注册处理的函数,可以完成注册信号的缺省动作,捕捉动作,也可以忽略信号
参数:
signum:要操作的信号值是多少
handler:你要处理的处理函数是什么,把函数名写在这里,这个参数有三种可以填写的方式:
- SIG_IGN:忽略这个信号,如果来了这个信号,我们也不会去响应它
- SIG_DFL:按照缺省方式响应这个信号
- 信号响应函数名:当接收到信号的时候,去执行这个函数里面的内容
返回值:成功则返回 handler 所设置的值,失败则返回 SIG_ERR,errno 会被设置
#include <signal.h>
/* Prototype for the glibc wrapper function */
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
函数功能:用来设置一系列信号是否需要阻塞操作
参数:
how:指定信号的操作方式
SIG_BLOCK:在原有的信号设置的基础上,添加上一些阻塞设置
SIG_UNBLOCK:解开指定的信号的阻塞
SIG_SETMASK:用新的信号设置覆盖旧的信号设置,添加阻塞设置
set:需要去添加阻塞的信号集合变量地址
oldset:这里是用来接收保存旧的信号设置集合,如果为 NULL,则代表不去接收旧的设置
信号集合操作函数:
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
函数功能:
sigemptyset:清空信号集合变量
sigfillset:将所有的信号都填充到指定的信号集合变量中
sigaddset:添加某个信号到信号集合中
sigdelset:删除信号集合中的某个信号
sigismember:判断信号集合中是否存在某个信号
参数:
set:信号集合变量的地址
signum:指定的信号值是多少
返回值:
sigemptyset,sigfillset,sigaddset,sigdelset 这几个函数成功返回 0,失败返回-1;
sigismember,如果信号集合中有指定信号则返回 1,没有则返回 0,判断失败则返回-1
以上所有函数出错都会设置 errno。
信号的安全机制:
- 由于信号是异步的,你不知道它什么时候会过来,而这个时候我们刚好调用到某个
函数的时候,恰好被信号所打断,那执行完信号的响应,回来之后还能正常运行的
就被我们称之为信号的安全函数。 - 在阻塞时被挂起的信号是有优先级的。
- 优先响应实时信号,而事实信号里面也分优先级,优先响应最大的信号值
- 等实时信号响应完再响应非实时信号,非实时信号的响应不呈现规矩
- 为了信号响应的安全,或者是逻辑缜密性,我们一般不在信号响应函数中,操作全
局变量,除非你的逻辑没有问题(添加锁的概念)。
2 IPC通信机制
2.0 简介
IPC定义:内核当中为了增强进程与进程之间数据交互及效率的一种机制的对象。
IPC对象是内核提供的一种机制,允许不同的进程之间进行更高效地通信和数据交换。
IPC里有:
- 共享内存:速度最快的一种进程间通信方式,在内核空间(物理内存)中开辟一块内存出来,映射给不同的进程的虚拟内存中。因此不同的进程可以访问这块内存。
- 消息队列:一个增强型的管道
- 信号量:操作某种资源的进程间的同步互斥操作体系,相当于一个增强型的全局变量(全局于不同进程中),用来代表一种资源,当资源没有的时候,我们会陷入睡眠。
- 特点:
- 进程都可以访问这个变量
- 进程可以加减这个变量
- 当这个变量减到为 0 的时候,你再想去减它,他就让你进程陷入睡眠,等到这个变量的值你减的时候不会小于 0,他才会让你进程继续往下面工作
- 特点:
它们有较多共同的特性:
- 在系统中使用所谓键值(KEY)来唯一确定,类似于文件系统中的文件路径。
- 当某个进程创建(或打开)一个IPC对象时,将会获得一个整型ID,类似于文件描述符。
- IPC对象属于系统,而不是进程,因此在没有明确删除操作的情况下,IPC对象不会因为进程的退出而消失。
// 查看系统当前所有IPC对象
ipcs -a
// 查看系统当前的共享内存
ipcs -m
// 查看系统当前的消息队列
ipcs -q
// 查看系统当前的的信号量
ipcs -s
删除IPC对象:
ipcrm -Q key : 删除指定的消息队列
ipcrm -q id : 删除指定的消息队列
ipcrm -M key : 删除指定的共享内存
ipcrm -m id: 删除指定的共享内存
ipcrm -S key : 删除指定的信号量
ipcrm -s id: 删除指定的信号量
2.1 共享内存
使用共享内存的一般步骤是:
-
获取共享内存对象的ID,使用ftok()函数
-
将共享内存映射至本进程虚拟内存空间的某个区域:使用shmget()函数获取共享内存对象,
使用shmat()函数将共享内存映射到虚拟内存。
-
当不再使用时,解除映射关系:使用shmdt()函数取消内存映射。
-
当没有进程再需要这块共享内存时,删除它:使用shmctl(shm_id, IPC_RMID, NULL)函数将共享内存删除。
2.1.1 创建或打开SHM对象
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
返回值:SHM对象ID
参数key:SHM对象键值
参数size:共享内存大小
参数shmflg:创建模式和权限
- IPC_CREAT:如果key对应的共享内存不存在,则创建SHM对象
- IPC_EXCL:如果该key对应的共享内存已存在,则报错
- 权限与文件创建open类似,用八进制表示
2.1.2 映射 / 解除映射SHM对象
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr/*一般为NULL*/, int shmflg);
- 功能:
- 将指定的共享内存,映射到本进程内存空间
- 参数:
- shmid:指定的共享内存的ID
- shmaddr:指定映射后的地址,因为是虚拟地址,分配的原则要兼顾诸如段对齐、权限分配等问题,因此用户进程是无法指定的,只能由系统自动分配,因此此参数一般为NULL,表示交由系统来自动分配。
- shmflg:可选项
- 0:默认,代表共享内存可读可写。
- SHM_RDONLY:代表共享内存只读。
- 返回值:
- 共享内存映射后的虚拟地址入口。
2.1.3 取消映射和删除对象
使用完SHM对象后,需要将其跟进程解除关联关系,即解除映射,函数接口如下
#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);
共享内存也有一个control函数,可用于设置SHM对象属性信息、获取SHM属性信息、删除SHM对象等其余操作,接口如下:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- shmid:指定的共享内存的ID
- cmd:一些命令字
- IPC_STAT:获取共享内存 的一些信息,放入shmid_ds{ }中
- IPC_SET:将 buf 中指定的信息,设置到本共享内存中
- IPC_RMID:删除指定的共享内存,此时第三个参数 buf 将被忽略
- buf:用来存放共享内存信息的结构体
删除共享内存对象
shmctl(shmid, IPC_RMID, NULL);
3 消息队列
3.0 简介
最主要的特征是允许发送的数据携带类型,具有相同类型的数据在消息队列内部排队,读取的时候也要指定类型,然后依次读出数据。
消息队列实现了对消息发送方和消息接收方的解耦,使得双方可以异步处理消息数据,这是消息队列最重要的应用。
经典案例是系统日志:多个不同的、不相关的进程向同一管道输入数据。
3.1 创建或打开MSG对象
// 创建(或打开)消息队列
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
3.2 收发消息
向MSG对象发送消息
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
接口说明:
-
msgqid:MSG对象的ID,由msgget()获取。
-
msgp:一个指向等待被发送的消息的指针,由于MSG中的消息最大的特点是必须有一个整数标识,用以区分MSG中的不同的消息,因此MSG的消息会使用一个特别的结构体来表达,具体如下所示:
struct msgbuf { // 消息类型(固定) long mtype; // 消息正文(可变) // ... };
msgp就是一个指向上述结构体的指针。
- msgsz:消息正文的长度(单位字节),注意不含类型长度。
- msgflg:发送选项,一般有:
- 0:默认发送模式,在MSG缓冲区已满的情形下阻塞,直到缓冲区变为可用状态。
- IPC_NOWAIT:非阻塞发送模式,在MSG缓冲区已满的情形下直接退出函数并设置错误码为EAGAIN.
从对象接收消息:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
- msgqid:MSG对象的ID,由msgget()获取。
- msgp:存放消息的内存入口。
- msgsz:存放消息的内存大小。
- msgtyp:欲接收消息的类型:
- 0:不区分类型,直接读取MSG中的第一个消息。
- 大于0:读取类型为指定msgtyp的第一个消息(若msgflg被配置了MSG_EXCEPT则读取除了类型为msgtyp的第一个消息)。
- 小于0:读取类型小于等于msgtyp绝对值的第一个具有最小类型的消息。例如当MSG对象中有类型为3、1、5类型消息若干条,当msgtyp为-3时,类型为1的第一个消息将被读取。
- msgflg:接收选项:
- 0:默认接收模式,在MSG中无指定类型消息时阻塞。
- IPC_NOWAIT:非阻塞接收模式,在MSG中无指定类型消息时直接退出函数并设置错误码为ENOMSG.
- MSG_EXCEPT:读取除msgtyp之外的第一个消息。
- MSG_NOERROR:如果待读取的消息尺寸比msgsz大,直接切割消息并返回msgsz部分,读不下的部分直接丢弃。若没有设置该项,则函数将出错返回并设置错误码为E2BIG。
3.3 删除消息队列对象
要想显式地删除掉MSG对象,具体接口如下:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
接口说明:
- msqid:MSG对象ID
- cmd:控制命令字
- IPC_STAT:获取该MSG的信息,储存在结构体msqid_ds中
- IPC_SET:设置该MSG的信息,储存在结构体msqid_ds
- IPC_RMID:立即删除该MSG,并且唤醒所有阻塞在该MSG上的进程,同时忽略第三个参数
4 信号量组
4.1 基本概念
信号量本质上是一个数字,用来表征一种资源的数量,当多个进程或者线程争夺这些稀缺资源的时候,信号量用来保证他们合理地、有秩序地使用这些资源,而不会陷入逻辑谬误之中。
在Unix/Linux系统中常用的信号量有三种:
- IPC信号量组
- POSIX具名信号量
- POSIX匿名信号量
IPC信号量组:这种机制可以一次性在其内部设置多个信号量,实际上在其内部实现中,IPC信号量组是一个数组,里面包含N个信号量元素,每个元素相当于一个POSIX信号量。
- 临界资源(critical resources)
- 多个进程或线程有可能同时访问的资源(变量、链表、文件等等)
- 临界区(critical zone)
- 访问这些资源的代码称为临界代码,这些代码区域称为临界区
- P操作
- 程序进入临界区之前必须要对资源进行申请,这个动作被称为P操作,这就像你要把车开进停车场之前,先要向保安申请一张停车卡一样,P操作就是申请资源,如果申请成功,资源数将会减少。如果申请失败,要不在门口等,要不走人。
- V操作
- 程序离开临界区之后必须要释放相应的资源,这个动作被称为V操作,这就像你把车开出停车场之后,要将停车卡归还给保安一样,V操作就是释放资源,释放资源就是让资源数增加。
4.2 创建SEMD对象
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
接口说明:
- key:SEM对象键值
- nsems:信号量组内的信号量元素个数
- semflg:创建选项
- IPC_CREAT:如果key对应的信号量不存在,则创建之
- IPC_EXCL:如果该key对应的信号量已存在,则报错
- mode:信号量的访问权限(八进制,如0644)
创建信号量时,还受到以下系统信息的影响:
- SEMMNI:系统中信号量的总数最大值。
- SEMMSL:每个信号量中信号量元素的个数最大值。
- SEMMNS:系统中所有信号量中的信号量元素的总数最大值。
Linux中,以上信息在 /proc/sys/kernel/sem
中可查看。
int main()
{
key_t key = ftok(".", 1);
// 创建(若已有则打开)一个包含2个元素的信号量组
int id = semget(key, 2, IPC_CREAT|0666);
}
4.3 PV操作
对于信号量而言,最重要的作用就是用来表征对应资源的数量,所谓的P/V操作就是对资源数量进行 +n/-n 操作,既然只是个加减法,那么为什么不使用普通的整型数据呢?原因是:
- 整型数据的加减操作不具有原子性,即操作可能被中断。
- 普通加减法无法提供阻塞特性,而申请资源不可得时应进入阻塞。
举例说明,假设有一个信号量组SEM对象,包含两个元素,现在要对第0个元素进程P操作(即减操作),对第1个元素进程V操作(即加操作),则代码如下:
int main()
{
key_t key = ftok(".", 1);
// 创建(若已有则打开)一个包含2个元素的信号量组
int id = semget(key, 2, IPC_CREAT|0666);
// 定义包含两个P/V操作的结构体数组
struct sembuf op[2];
op[0].sem_num = 0; // 信号量元素序号
op[0].sem_op = -2; // P操作
op[0].sem_num = 0; // 选项默认0
op[1].sem_num = 1; // 信号量元素序号
op[1].sem_op = +3; // V操作
op[1].sem_num = 0; // 选项默认0
// 同时对第0、1号信号量元素分别进行P、V操作
semop(id, op, 2);
}
注意:
- P操作是申请资源,因此如果资源数不够的话会导致进程阻塞,这正是我们想要的效果,因为资源数不可为负数。
- V操作是释放资源,永远不会阻塞。
- SEM对象的一大特色就是可以对多个信号量元素同时进行P/V操作,这也是跟POSIX单个信号量的区别。
当操作结构体sembuf中的sem_op为0时,称为等零操作,即阻塞等待直到对应的信号量元素的值为零。
4.4 semctl操作
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ...);
接口说明:
- semid:信号量组的ID
- semnum:信号量组内的元素序号(从0开始)
- cmd:操作命令字:
- IPC_STAT:获取信号量组的一些信息,放入semid_ds{ }中
- IPC_SET:将 semid_ds{ } 中指定的信息,设置到信号量组中
- IPC_RMID:删除指定的信号量组。
- GETALL:获取所有的信号量元素的值。
- SETALL:设置所有的信号量元素的值。
- GETVAL:获取第semnum个信号量元素的值。
- SETVAL:设置第semnum个信号量元素的值。
当参数cmd为IPC_RMID时,意为删除SEM对象,这操作与其他两种IPC对象一样。
一般而言,SEM对象在刚被创建出来的时候需要进行初始化,该命令字可以执行初始化的操作。
// 操作联合体
union 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 */
};
int main(void)
{
// 创建(或打开)SEM对象
// 注意需要指定 IPC_EXCL 模式,因为要区分是否是新建的SEM对象
int semid = semget(key, 1, IPC_EXCL|IPC_CREAT|0666);
// 1,若SEM对象已存在,则重新打开即可
if(semid == -1 && errno == EEXIST)
semid = semget(key, 1, 0);
// 2,若SEM对象为当前新建,则需进行初始化
else if(semid > 0)
{
union semun a;
a.val = 1; // 假设将信号量元素初始值定为1
semctl(semid, 0, SETVAL, a);
}
}
注意:
- semun是一个联合体,各成员对应不同的命令字。
- 该联合体须由调用者自己定义。
例程代码:
// 信号量组操作封装函数 sem.c
union semun
{
int val;
struct semid_ds *buf;
unsigned short *array;
struct seminfo *__buf;
};
void init_sem(int semid, int val)
{
union semun a;
a.val = val;
semctl(semid, 0, SETVAL, a);
}
void sem_p(int semid)
{
struct sembuf a;
a.sem_num = 0;
a.sem_op = -1;
a.sem_flg = 0;
semop(semid, &a, 1);
}
void sem_v(int semid)
{
struct sembuf a;
a.sem_num = 0;
a.sem_op = 1;
a.sem_flg = 0;
semop(semid, &a, 1);
}
// 数据发送者 sender.c
int main(void)
{
// 产生两个IPC对象的键值key
key_t key1 = ftok(".", 1);
key_t key2 = ftok(".", 2);
// 创建并映射SHM对象
int shmid = shmget(key1, 1024, IPC_CREAT|0666);
char *p = shmat(shmid, NULL, 0);
// 创建或打开SEM对象
int semid = semget(key2, 1, IPC_EXCL|IPC_CREAT|0666);
if(semid == -1 && errno == EEXIST)
semid = semget(key2, 1, 0);
else if(semid > 0)
init_sem(semid, 1);
while(1)
{
fgets(p, 1024, stdin);
sem_v(semid);
}
return 0;
}
// 数据接收者 receiver.c
int main(void)
{
// 产生两个IPC对象的键值key
key_t key1 = ftok(".", 1);
key_t key2 = ftok(".", 2);
// 创建并映射SHM对象
int shmid = shmget(key1, 1024, IPC_CREAT|0666);
char *p = shmat(shmid, NULL, 0);
// 创建或打开SEM对象
int semid = semget(key2, 1, IPC_EXCL|IPC_CREAT|0666);
if(semid == -1 && errno == EEXIST)
semid = semget(key2, 1, 0);
else if(semid > 0)
init_sem(semid, 1);
while(1)
{
printf("收到:%s", p);
sem_p(semid);
}
return 0;
}
5 POSIX信号量
POSIX
提供信号量的机制:在内核中诞生一个信号量文件(一般存放在``/dev/shm 路径之下)用于替换
IPC `机制中非常麻烦的信号量集合操作体制,给我们开发人员提供一套非常方便且功能一样的信号量体制。
sem_open()
#include <fcntl.h> /* For O_* constants */
#include <sys/stat.h> /* For mode constants */
#include <semaphore.h>
sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag,
mode_t mode,
unsigned int value);
函数功能:打开一个 posix
有名信号量。
参数:
-
name:有名信号量的文件名字,必须要以/作为开头
-
oflag:操作标志:
-
O_CREAT
:如果信号量不存在则创建 -
O_EXCL
:如果信号量存在则直接返回错误
-
-
mode:信号量操作权限
-
value:给信号量设置的初值
返回值:成功返回一个信号量指针,出错返回 SEM_FAILED
,``errno `会被设置。
sem_close()
#include <semaphore.h>
int sem_close(sem_t *sem);
函数功能:关闭一个信号量
参数:Sem:关闭的信号量指针
返回值:成功返回 0,失败返回-1,errno 会被设置
sem_wait
:
#include <semaphore.h>
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout)
函数功能:sem_wait:信号量 P(减)操作函数,如果没有资源,则一直等待 V操作
sem_trywait:尝试去 P 操作,如果 P 操作失败也不会阻塞在这里
sem_timedwait:指定等待时间去 P 操作,时间一到不再阻塞在这里等待资源
函数参数:
sem:操作的信号量
abs_timeout:等待的时间限制
struct timespec
{
time_t tv_sec; /* 秒级单位*/
long tv_nsec; /* 微秒级别单位 */
};
函数返回值:成功返回 0,失败返回-1,errno 会被设置。
sem_post()
#include <semaphore.h>
int sem_post(sem_t *sem);
函数功能:给信号量进行 V(加)操作。
函数参数:sem操作的信号量
函数返回值:成功返回 0,失败返回-1
线程
1、线程基本API
在大多数操作系统中,每创建一个线程都需要为其分配独立的栈内存。
1,线程例程指的是:如果线程创建成功,那么该线程会立即去执行的函数。
2, POSIX
线程库的所有 API
对返回值的处理原则都是一致的:POSIX 线程库的函数在失败时不会设置全局变量 errno
,而是直接将错误码作为返回值返回。因此,检查错误时应直接使用函数的返回值。
3, 线程属性如果为 NULL,则会创建一个标准属性的线程。
线程的警戒区指的是没有任何访问权限的内存,用来保护相邻的两条线程的栈空间不被彼此践踏。
1、线程的创建
线程特点:
1, 称之为调度的最小单位,CPU的任务调度都是以线程为单位去调度的。
2, 所有的进程里面都有至少一条线程。
3, 如果是同个进程的线程,共享所有资源。
4, 任务队列的依赖原型。
5, 每个线程独立拥有一片栈空间,默认大小为 2-8M。
#include <pthread.h>
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine) (void *),
void *arg);
函数功能:在进程当中创建出一条线程。
参数说明:
- thread:用于存储新线程的标识符(线程 ID)
attr
:线程属性,若创建标准线程则该参数可设置为NULL。- 线程的属性变量,创建线程的时候会依赖于这个线程的属性变量去创建出一个线程,这个参数如果需要使用,需要调用
pthread_attr_init
函数初始化它。如果这个参数为 NULL,则代表按照默认属性创建一个标准的线程。 - 线程的属性:线程的分离属性;线程的栈空间大小;线程的优先级。
- 线程的属性变量,创建线程的时候会依赖于这个线程的属性变量去创建出一个线程,这个参数如果需要使用,需要调用
- start_routine:线程函数,一个函数指针类型,新的线程将会去执行这个函数的内容。
arg
:线程函数的参数。
返回值:成功返回 0,失败返回一个错误的值,errno 不会被设置。
线程最重要的特性是并发,线程函数会与主线程同时运行,这是它与普通函数调用的根本区别。注意:由于线程函数的并发性,在线程中访问共享资源需要特别小心,因为这些共享资源会被多个线程争抢,形成“竞态”。最典型的共享资源是全局变量。
2、线程的退出
与进程类似,线程退出之后不会立即释放其所占有的系统资源,而是会成为一个僵尸线程。其他线程可使用 pthread_join()
来释放僵尸线程的资源,并可获得其退出时返回的退出值,该接口函数被称为线程的接合函数。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
函数功能:等待指定的线程退出并释放线程的资源,并且用函数的第二个参数接收其线程的返回值。
参数:
- thread:指定等待退出的线程的 ID。
retval
:用来接收线程退出的返回值的 void *的变量的地址,如果不想接收可以直接填入 NULL。
返回值:成功返回 0,失败返回一个错误的值,errno
不会被设置。
#include <pthread.h>
void pthread_exit(void *retval);
函数功能:当一个线程调用 pthread_exit()
时,这个函数使得终止调用该函数的线程终止,并且可以传递一个退出状态给其他线程,通常是主线程。
参数:这是一个指向线程退出状态的指针。这个值可以被其他线程通过 pthread_join()
函数获取。如果不需要传递退出状态,可以传递 NULL。
注意:参数retval
是线程的返回值,对应线程执行函数的返回值。若线程没有数据可返回则可写成NULL。
3、线程其他知识
3.1 获取线程自身的ID
#include <pthread.h>
pthread_t pthread_self(void);
注意:进程的PID
是系统全局资源,而线程的TID
仅限于进程内部的线程间有效。当我们要对某条线程执行诸如发送信号、取消、阻塞接合等操作时,需要用到线程的ID。
3.2 线程错误码
线程函数对系统错误码的处理跟标准C库函数的处理方式有很大不同,标准C库函数会对全局错误码 errno
进行设置,而线程函数发生错误时会直接返回错误码。
以线程接合为例,若要判定接合是否成功,成功的情况下输出僵尸线程的退出值,失败的情况下输出失败的原因,那么实现代码应这么写:
void *val;
errno = pthread_join(tid, &val);
if(errno == 0)
printf("成功接合线程,其退出值为:%ld", (long)val);
else
printf("接合线程失败:%s\n", strerror(errno)); // 注意需包含头文件 string.h
所有以 pthread_xxx
开头的线程函数,成功一律返回0,失败一律返回错误码。这是因为 pthread_create()
返回的错误码是专门为线程库定义的,它们与 errno
定义的错误码是不同的
3.3 函数单例
希望某个函数只被严格执行一次,这种需求在一些初始化功能模块中尤为常见。
由于线程的并发特性,我们无法预先知晓哪条线程会对信号量进行初始化,于是就希望有一种只执行一遍的函数单例,可以被众多的并发线程放心去调用。这种机制可以用如下函数达成:
#include <pthread.h>
// 函数单例控制变量
pthread_once_t once_control = PTHREAD_ONCE_INIT;
// 函数单例启动接口
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
接口说明:
- once_control是一种特殊的变量,用来关联某个函数单例,被关联的函数单例只会被执行一遍。
init_routine
函数指针指向的函数就是只执行一遍的函数单例。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <pthread.h>
// 函数单例控制变量
pthread_once_t once_control = PTHREAD_ONCE_INIT;
void init_routine(void)
{
printf("我会被严格执行一遍。\n");
}
void *f(void *arg __attribute__((unused)))
{
pthread_once(&once_control, init_routine);
pthread_exit(NULL);
}
int main(void)
{
pthread_t tid;
for(int i=0; i<20; i++)
pthread_create(&tid, NULL, f, NULL);
pthread_exit(NULL);
}
3.4 设置线程属性
线程属性的操作步骤:
1,定义线程属性变量,并且使用 pthread_attr_init( )
初始化。
2,使用 pthread_attr_setXXX( )
来设置相关的属性。
3,使用该线程属性变量创建相应的线程。
4,使用 pthread_attr_destroy( )
销毁该线程属性变量。
3.4.1 线程分离属性
一条线程如果是可接合的,意味着这条线程在退出时不会自动释放自身资源,而会成为僵尸线程,同时意味着该线程的退出值可以被其他线程获取。因此,如果不需要某条线程的退出值的话,那么最好将线程设置为分离状态,以保证该线程不会成为僵尸线程。
线程的属性中有两种分离属性,一种是完全分离属性,一种是可接合属性,默认创建出来的线程都是可接合属性即可以调用 pthread_join 函数去等待其退出,并且接收其线程返回值。
线程的分离属性函数:
#include <pthread.h>
// 线程的设置分离属性的函数
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
// 线程的获取分离属性设置情况的函数
int pthread_attr_getdetachstate(const pthread_attr_t *attr,
int *detachstate);
参数:
-
attr
:线程属性结构体地址,这个结构体一定是要结果线程的属性初始化函数初始化之后才可以使用的。 -
detachstate
:分离属性:PTHREAD_CREATE_DETACHED
:完全分离属性PTHREAD_CREATE_JOINABLE
:可接合属性
返回值:成功返回 0,失败返回一个错误值,errno
不会被设置。
与 pthread_detach()
的区别
pthread_attr_setdetachstate()
:- 在线程创建前设置线程的分离状态。
- 适用于需要在创建线程时指定分离状态的场景。
pthread_detach()
:- 在线程创建后将线程设置为分离状态。
- 适用于需要在运行时动态设置分离状态的场景。
int pthread_detach(pthread_t thread);
3.4.2 设置线程中栈大小的属性
每一个线程的栈都是独立的。
注意:进行栈的相关设置前,必须添加宏定义#define _GNU_SOURCE
#include <pthread.h>
int pthread_getattr_np(pthread_t thread, pthread_attr_t *attr);
参数:
- thread:线程的 ID
- attr:线程属性结构体的地址
返回值:成功的情况下,返回值为 0,并且会将指定 ID 的线程的属性存放到 attr
当中,失败返回非 0 值,errno
不会被设置。
设置线程栈大小
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
参数:
- attr:线程属性结构体
- stacksize:你需要设置的线程的栈大小,以字节为单位
返回值:成功的情况下,返回值为 0,失败返回非 0 值,errno 不会被设置。
获取线程栈的大小
nt pthread_attr_getstacksize(const pthread_attr_t *attr, size_t *stacksize);
参数:
- attr:线程属性结构体
- stacksize:获取到的线程的栈大小,以字节为单位
返回值:成功的情况下,返回值为 0,失败返回非 0 值,errno 不会被设置。
注意:volatile的作用是拒绝编译器优化,实时访问内存。
线程并发执行,执行printf函数需要花费时间,无法保证哪条线程谁跑得快。
3.4.3 设置线程优先级属性
概念:非实时线程、实时线程、动态优先级、静态优先级
实时线程是指那些需要在严格定义的时间内完成其任务的线程,它们通常用于对时间敏感的应用,如嵌入式系统、工业控制系统等。
特点:
a) 实时线程分 99 个静态优先级,数字越大,优先级越高。
b) 高优先级的实时线程会完全抢占低优先级实时线程的资源(指令运行资源)。
c) 在实时线程当中支持抢占调度策略跟轮询调度策略。
d) 拥有抢占所有实时线程运行资源的能力。
e) 必须拥有超级用户权限才能够运行。
非实时线程是指那些不要求在严格定义的时间内完成其任务的线程。
单位时间中,里面只有一个静态优先级 0,也就是在非实时线程中,它是没有静态优先级的概念的,他的所有的执行过程都是由系统自动分配的。
特点:
a) 非实时线程只有一个静态优先级,所以同时非实时线程的任务无法抢
占他人的资源
b) 在非实时线程当中只支持其他调度策略(自动适配的,系统分配的调
度策略)
c) 不拥有抢占所有运行资源的能力
d) 支持动态优先级系统自适应,从-20 到 19 的动态优先级(nice 值)
动态优先级和静态优先级是两种不同的线程或进程调度策略。
静态优先级是在线程或进程创建时确定的,并且在整个运行期间保持不变,除非显式地进行修改。
动态优先级是操作系统根据线程的行为和系统状态动态调整的优先级。
1.抢占式调度策略SCHED_FIFO
,在同一静态优先级的情况下,抢占调度策略的线程一旦开始运行便会一直抢占 CPU 资源,而其他同一优先级的只能一直等到这个抢占式调度策略的线程退出才能被运行到(非实时线程会有一小部分资源分配到)。
2, 轮询式调度策略,在同一静态优先级的情况下,大家一起合理瓜分时间片,不会一直抢占 CPU 资源(非实时线程会有一小部分资源分配到)。每一个 SHCED_RR 策略下的线程都将会被分配一个额度的时间片,当时间片耗光时,他会被放入其所在优先级队列的队尾的位置。
3,当线程的调度策略为 SCHED_OTHER 时,其静态优先级(static priority)必须设置
为 0。该调度策略是 Linux 系统调度的默认策略,处于 0 优先级别的这些线程按照所谓的动
态优先级被调度。
注意点:抢占式调度策略跟轮询式调度策略只能在实时线程中被设置,也就是静态优先级 1-99 的区域内设置,普通非实时线程不能设置。
设置线程是否继承父线程调度策略:
#include <pthread.h>
int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);
参数:
- attr:线程的属性结构体
- inheritsched:是否继承父线程的调度策略
- PTHREAD_EXPLICIT_SCHED:不继承,只有不继承父线程的调度策略才可以设置线程的调度策略
- PTHREAD_INHERIT_SCHED:继承父进程的调度策略。
返回值:成功的情况下,返回值为 0,失败返回非 0 值,errno 不会被设置。
设置线程的调度策略属性
#include <pthread.h>
int pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy);
参数:
- attr:线程的属性结构体
- policy:调度策略
- SCHED_FIFO:抢占式调度,同一优先级中,一旦运行到设置了这个参数的线程 CPU 将会一直被该线程所占领,不会分配资源给其他实时线程,会分配一点资源给非实时线程。
- SCHED_RR:轮询式调度,同一优先级总,遇到这个设置的线程,将会给其运行一段时间后,又继续给下一个人运行(相当于大家平均运行),会分配一点资源给非实时线程。
- SCHED_OTHER:其他普通的调度策略,仅能设置与 0 静态优先级,也就是非实时线程,让这条线程成为一个由系统去自动根据动态优先级分配资源的任务。
返回值:成功的情况下,返回值为 0,失败返回非 0 值,errno 不会被设置。
设置静态优先级:
#include <pthread.h>
int pthread_attr_setschedparam(pthread_attr_t *attr,
const struct sched_param *param);
参数:
attr:线程的属性结构体。
param:优先级结构体,里面只有元素 sched_priority,用来登记线程的静态优先级的值。
struct sched_param
{
int sched_priority; /* Scheduling priority */
};
返回值:成功的情况下,返回值为 0,失败返回非 0 值,errno 不会被设置。
3.5 线程的取消机制
我们可以给某个指定的线程发送取消指令,当这个线程收到这条取消指令之后,便会退出这条线程,我们把这种机制,称为线程的取消机制。
线程的取消函数:
#include <pthread.h>
int pthread_cancel(pthread_t thread);
函数功能:给指定的线程发送一个取消指令,该线程默认收到此取消指令后会退出线程(线程运行到取消点函数的时候)。
参数:thread:指定的线程 ID
返回值:成功返回 0,失败返回一个错误的数字,errno 不会被设置。
在 POSIX 线程库中,取消点函数(Cancellation Points) 是线程在执行过程中可以响应取消请求(Cancellation Request)的函数。当线程收到取消请求时,只有在执行取消点函数时才会真正终止线程。取消点函数是线程安全终止的关键机制。
取消点函数的作用:
- 响应取消请求:
- 线程可以通过
pthread_cancel()
向其他线程发送取消请求。 - 取消点函数是线程检查是否收到取消请求并终止执行的时机。
- 线程可以通过
- 资源清理:
- 在取消点函数中,线程可以执行必要的资源清理操作(如释放锁、关闭文件等)。
线程的取消状态函数:
#include <pthread.h>
int pthread_setcancelstate(int state, int *oldstate);
函数功能:设置线程是否可以被取消指令所取消。
参数:
-
state:设置的状态。
-
PTHREAD_CANCEL_ENABLE
:使能线程的取消功能,代表线程可以被取消指令所退出 -
PTHREAD_CANCEL_DISABLE
:关闭线程的取消功能,代表线程就算接收到取消指令也不会有所动作。
-
-
oldstate:旧的状态值回存放到这个内存中,这个参数可以为 NULL。
返回值:成功返回 0,失败返回一个错误的数字,errno 不会被设置。
线程的取消类型函数:
int pthread_setcanceltype(int type, int *oldtype);
函数功能:设置线程收到取消指令后是立即退出线程还是遇到取消点函数才退出线程。
参数:
-
type:取消指令的响应类型:
-
PTHREAD_CANCEL_DEFERRED:延时响应,代表线程接收到取消指令后,遇到取消点函数才会退出。该类型是默认的取消类型。
-
PTHREAD_CANCEL_ASYNCHRONOUS:立即响应,代表线程接收到取消指令后,马上退出,就算没有遇到取消点函数。
-
-
oldtype:原本设置的取消指令的响应类型,他会存放到这个内存中,可以设置为 NULL
返回值:成功返回 0,失败返回一个错误的数字,errno 不会被设置。
注意:当线程使用 pthread_setcancelstate()
禁用取消后,如果收到取消请求,该请求会被挂起(即暂时不会被处理)。当线程重新启用取消时,挂起的取消请求会被立即处理,线程会根据当前的取消类型(延迟取消或异步取消)终止执行。
线程机制的完善:
由于线程是可以被取消(杀死),而且是异步的操作(你不知道什么时候会接收到线程的取消指令),所以很有可能导致某个正在被执行的逻辑操作被迫终端从而出现逻辑谬误。因此,出现一种机制,可以在线程取消前执行一些特定的操作解决可能出现的 逻辑漏洞。
#include <pthread.h>
void pthread_cleanup_push(void (*routine)(void *), void *arg);
void pthread_cleanup_pop(int execute);
- 函数功能:
pthread_cleanup_push:取消指令退出线程前的注册函数,用于执行该函数过后,如果接收到线程的取消指令,则先去执行该函数所注册的函数后,才能退出线程 - pthread_cleanup_pop:必须与上面的函数配套使用,用于清除出注册的函数代表以后接收到取消指令我们也不会去执行注册的函数。
参数:
- routine:如果接收到取消指令,则去执行这个函数指针所代表的函数的内容。
- arg:这个是传输给 routine 这个函数指针的函数的参数。
- execute:如果该值为 0,则清除出注册函数,而不去执行注册函数的内容,如果该值为非 0,则清除出注册函数的同时,去运行注册函数的内容。
4 互斥锁
4.1 基本逻辑
任何一条线程要开始运行互斥区间的代码,都必须先获取互斥锁,而互斥锁的本质是一个二值信号量,因此当其中一条线程抢先获取了互斥锁之后,其余线程就无法再次获取了,效果相当于给相关的资源加了把锁,直到使用者主动解锁,其余线程方可有机会获取这把锁。
互斥锁可以看作是一种特殊的二进制信号量(semaphore),其值只能是0或1。当值为1时,表示资源可用,线程可以获取锁;当值为0时,表示资源被占用,其他线程必须等待。
简单来说就是为了用于多线程编程中的同步机制,用于保护共享资源,防止多个线程同时访问或修改同一资源,从而避免数据竞争和不一致性问题。
互斥锁的核心概念:互斥性、原子性、阻塞性
- 互斥性:
- 互斥锁确保同一时刻只有一个线程可以持有锁,其他线程必须等待锁被释放后才能获取。
- 原子性:
- 互斥锁的操作(加锁、解锁)是原子的,不会被其他线程打断。
- 阻塞性:
- 如果锁已被其他线程持有,尝试获取锁的线程会阻塞,直到锁被释放。
4.2 初始化和销毁
初始化互斥锁有两种办法:
- 静态初始化
- 动态初始化
静态初始化就是在定义同时赋予其初值:
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
由于静态初始化互斥锁不涉及动态内存,因此无需显式释放互斥锁资源,互斥锁将会伴随程序一直存在,直到程序退出为止然后自动释放互斥锁的资源。
而所谓动态初始化指的是使用 pthread_mutex_init()
给互斥锁分配动态内存并赋予初始值,因此这种情形下的互斥锁需要在用完之后显式地进行释放资源。
#include <pthread.h>
// 初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
// 销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
接口说明:
-
mutex:互斥锁的地址
-
attr:互斥锁属性(一般设置为NULL)代表按照默认的互斥锁进行初始化
-
返回值:成功则返回 0,失败返回一个错误值,errno 不会被设置。
4.3 加锁和解锁
函数功能:
pthread_mutex_lock
:获取指定的互斥锁(加锁操作),如果这个互斥锁已经被别人使用了(被别人加锁操作了),则你的线程会在这里陷入睡眠。
具体接口如下:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
具体操作如下:
#include <pthread.h>
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
// 加锁
pthread_mutex_lock( &m );
// 解锁
pthread_mutex_unlock( &m );
函数功能:
pthread_mutex_lock:获取指定的互斥锁(加锁操作),如果这个互斥锁已经被别人使用了(被别人加锁操作了),则你的线程会在这里陷入睡眠。
pthread_mutex_trylock:尝试去获取指定的互斥锁,如果这个互斥锁已经被别人使用了,他不会在这里睡眠,直接返回错误。
pthread_mutex_unlock:解除互斥锁的占用(解锁操作),如果这个时候有人因为获取这把锁而陷入睡眠,则会将最先去获取这把锁的人唤醒,让其继续操作这把锁。
总结:
1, 什么时候我们需要用到互斥锁:一旦操作一个共有资源,我们都应该加锁,以确保逻辑没有漏洞。
2, 为了防止死锁(线程在执行的过程中,获取了互斥锁,而刚好被取消掉,你的锁来不及解开就会造成死锁),我们会在获取锁之后的操作中,加上pthread_cleanup_push 跟 pthread_cleanup_pop 函数,包裹进线程的锁操作中
5 读写锁
5.1 基本逻辑
对于互斥锁而言,凡是涉及临界资源的访问一律加锁,这在并发读操作的场景下会大量浪费时间。要想提高访问效率,就必须要将对资源的读写操作加以区分:读操作可以多任务并发执行,只有写操作才进行恰当的互斥。这就是读写锁的设计来源。
简单来说就是用于在多线程环境中控制对共享资源的访问。它允许多个线程同时读取共享资源,但写操作需要独占访问。
5.2 初始化和定义 销毁锁
读写锁也是一种特殊的变量。
#include <pthread.h>
// 静态初始化:
pthread_rwlock_t rw = PTHREAD_RWLOCK_INITIALIZER;
// 动态初始化与销毁:
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
函数功能:
pthread_rwlock_init
:读写锁的初始化函数pthread_rwlock_destroy
:读写锁的销毁函数
参数
rwlock
:要操作的读写锁变量
attr
:读写锁的属性,我们一般设置为 NULL,代表创建一个标准的读写锁。
返回值:成功则返回 0,失败返回一个错误值,errno 不会被设置。
5.3 加锁和解锁
读写锁最大的特点是对即将要做的读写操作做了区分:
- 读操作可以共享,因此多条线程可以对同一个读写锁加多重读锁
- 写操作天然互斥,因此多条线程只能有一个拥有写锁。(注意写锁与读锁也是互斥的)
操作原则:
- 如果只对数据进行读操作,那么就加 → 读锁。在读的同时,不可以进行写操作。
- 如果要对数据进行写操作,那么就加 → 写锁。在写的同时,不可以进行读操作。
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
函数功能:
pthread_rwlock_rdlock
:加上一个读锁,如果在这之前有人加上了读锁,这个操作也能够成功加上读锁,但是如果之前有人加上了写锁,则这个函数会陷入阻塞。
pthread_rwlock_wrlock:加上一个写锁,如果前面有人加上了读锁或者是写锁,都会让我们这个操作陷入阻塞。
pthread_rwlock_unlock:解锁一个读锁或者是写锁(读写锁在解锁的时候是不分类型的)。
参数:rwlock
要操作的读写锁。
返回值:成功返回 0,失败返回一个错误值,errno
不会被设置。
6 条件变量
6.1 初始化以及销毁条件变量
简单来说就是条件变量的核心作用是允许线程在某个条件不满足时进入等待状态,并在条件满足时被唤醒。
在许多场合中,程序的执行通常需要满足一定的条件,条件不成熟的时候,任务应该进入睡眠阻塞等待,条件成熟时应该可以被快速唤醒。另外,在并发程序中,会有其他任务同时访问该条件,因此任何时候都必须以互斥的方式对条件进行访问。条件量就是专门解决上述场景的逻辑机制。
- 等待(Wait):
- 线程在条件不满足时,释放互斥锁并进入等待状态。
- 当条件满足时,线程被唤醒并重新获取互斥锁。
- 通知(Signal/Broadcast):
- Signal:唤醒一个等待的线程。
- Broadcast:唤醒所有等待的线程。
- 与互斥锁的关系:
- 条件变量必须与互斥锁配合使用,以确保对共享资源的访问是线程安全的
-
静态初始化:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
-
动态初始化:
pthread_cond_init(&cond, NULL);
参数:
cond:需要初始化的条件变量。
Attr:这个参数设置为 NULL,代表按照默认的条件进行初始化。
返回值:成功则返回 0,失败返回一个错误值,errno 不会被设置。
比较两者特性:
特性 | 静态初始化 | 动态初始化 |
---|---|---|
初始化方式 | 直接赋值 PTHREAD_COND_INITIALIZER | 调用 pthread_cond_init() |
适用场景 | 全局或静态条件变量 | 局部或动态分配的条件变量 |
是否需要销毁 | 不需要 | 需要调用 pthread_cond_destroy() |
灵活性 | 较低 | 较高 |
属性配置 | 不支持 | 支持通过 attr 参数配置 |
销毁条件变量:
#include <pthread.h>
int pthread_ cond _destroy(pthread_ cond _t * cond);
参数:
Mutex:销毁的条件变量
返回值:
成功则返回 0,失败返回一个错误值,errno 不会被设置
说明:
- 在进行条件判断前,先加锁(防止其他任务并发访问)
- 成功加锁后,判断条件是否允许
- 若条件允许,则直接操作临界资源,然后释放锁。
- 若条件不允许,则进入条件量的等待队列中睡眠,并同时释放锁。
- 在条件量中睡眠的任务,可以被其他任务唤醒,唤醒时重新判定条件是否允许程序继续执行,当然也是必须先加锁。
6.2 操作条件变量
互斥锁提供锁住临界资源的功能,条件量提供阻塞睡眠和唤醒的功能。
要点:
-
放入条件变量的等待队列
-
阻塞自己的同时释放锁
-
被唤醒后重新获取锁
#include <pthread.h>
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
一旦调用这个函数,线程便会经历以下步骤,一直等待被唤醒:
解开互斥锁-》线程睡眠-》等待被唤醒…
被唤醒后-》线程启动-》给互斥锁加锁
pthread_cond_timedwait
:规定等待时间的条件变量等待函数,跟上面的函数功能跟逻辑是一样的,只是多加了一个时间参数,超过这个时间便不会等待,直接返回错误。
参数:
cond
:要操作的条件变量
mutex
:要操作的互斥锁
abs_timeout
:等待的时间限制
struct timespec
{
time_t tv_sec
; /* 秒级单位*/
long tv_nsec
; /* 微秒级别单位 */
};
返回值:成功返回 0,失败返回错误数值,errno 不会被设置。
条件变量的唤醒函数:
#include <pthread.h>
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
函数功能:
pthread_cond_signal:唤醒一个最早因为条件变量而陷入睡眠的线程。
pthread_cond_broadcast:唤醒全部因为条件变量而陷入睡眠的线程。
参数:cond:要操作的条件变量。
返回值:成功返回 0,失败返回错误数值,errno 不会被设置。
6.3 条件变量注意事项
虚假唤醒:
- 线程可能在没有收到通知的情况下被唤醒,因此条件变量的等待操作通常放在
while
循环中。 - 互斥锁的保护:
- 在调用
pthread_cond_wait()
前必须持有互斥锁,调用后会释放互斥锁,唤醒后会重新获取互斥锁。
- 在调用
- 条件变量的销毁:
- 动态初始化的条件变量在使用完毕后必须调用
pthread_cond_destroy()
销毁。
- 动态初始化的条件变量在使用完毕后必须调用
7 可重入函数
概念:在多线程的调用中,一个函数,被多个线程在同一时刻被调用,如果这个函数还是能够保持原本的功能及返回值,那我们就把这个函数称之为可重入函数。
使用要点:
1, 不要使用不可重入函数。
2, 尽量不要在线程里面使用静态变量,使用临时变量或者是堆内存的数据。
同步原语:互斥锁、条件变量、信号量、读写锁、屏障、自旋锁。