这里写自定义目录标题
- 2 linux多进程与多线程
- 2.1 进程间通信
- 2.1.1 管道
- 2.1.2 信号
- 2.1.3 消息队列
- 2.1.4 共享内存
- 3 线程
- 4 IO多路复用
- 4.1 非阻塞IO
- 4.2 IO多路复用
2 linux多进程与多线程
学习并发程序。
linux系统中,使用树型管理进程。因此进程之间有父子关系。通过getpid()
、getppid()
函数,获取进程ID。
1 进程的地址空间:
一旦进程建立之后, 系统则要为这个进程分配相应的资源。32位的系统,一般系统会分配4G的地址空间。这个地址空间结构:
4G
: 内核空间0-3G
: 用户空间。用户空间有具体分为:- stack: 存放非静态的局部变量
- heap: 动态申请的内存
- .bss: 未初始化过的全局变量(包括初始化为 0 的, 未初始化过的静态变量 (包括初始化为 0)
- .data: 初始化过并且值不为 0 的全局变量, 初始化过的不为 0 静态变量
- .rodata: 只读变量(字符串之类)
- .text: 程序文本段(包括函数,符号常量)
- 当用户进程需要通过内核获取资源时, 会切换到内核态运行, 这时当前进程会使用内核空间的资源
注意,这里的4G地址空间,是虚拟空间,不是占有实际的内存地址。虚拟地址空间中的每个地址都是一个虚拟地址,虚拟地址只是一个用于寻址的编号。
- 虚拟地址空间,好比一张地图。地图本身是一张纸,而不是真实的地点;地图上标注的每一个地点只是一个映射关系。
- 物理地址:是指内存设备中真实存在的存储空间的编号
虚拟地址通过映射的方式建立与物理地址的关联, 从而达到访问虚拟地址就可以访问到对应的物理地址。
- 在 cpu 中有一个硬件 MMU(内存管理单元) , 负责虚拟地址与物理地址的映射管理以及虚拟地址访问
- 操作系统可以设置 MMU 中的映射内存段
在操作系统中使用虚拟地址空间主要是基于以下原因
- 直接访问物理地址, 会导致地址空间没有隔离, 很容易导致数据被修改
- 通过虚拟地址空间可以实现每个进程地址空间都是独立的,操作系统会映射到不用的物理地址区间,在访问时互不干扰.
2 进程的状态管理:状态机
进程的状态一般分为如下:
- 运行态 (TASK_RUNNING)。就绪或者正在进行都属于运行态
- 睡眠态 () : 此时进程在等待一个事件的发生或某种系统资源
- 可中断的睡眠 (TASK_INTERRUPT) : 可以被信号唤醒或者等待事件或者资源就绪
- 不可中断的睡眠 (TASK_UNTERRUPT) : 只能等待特定的事件或者资源就绪
- 停止态 (TASK_STOPPED) : 进程暂停接受某种处理。例如:gdb 调试断点信息处理。
- 僵尸态(TASK_ZOMBIE):进程已经结束但是还没有释放进程资源
3 并发与并行:
- 并行 : 在 cpu 多核的支持下,实现物理上的同时执行
- 并发 : 在有限的 cpu 核心的情况下(如只有一个 cpu 核心) , 利用快速交替 (时间片轮转) 执行来达到宏观上的同时执行。软件开发中,提高任务执行效率是通过并发
4 程序的并发执行:同一个时间段,有多个任务在同时执行,由操作系统调度算法来实现,比较典型的是时间片轮转
- 假定一个时间片为1s,由操作系统来分配每个任务的时间片数量
- 一旦一个任务的时间片消耗完,则操作系统会切换下一个任务到cpu中执行
- 如果没有执行完成,则等待下一次分配
并行是基于硬件完成,而并发则可以使用软件算法来完成, 在完成任务时,可以创建多个进程并发执行
通过 fork() 函数创建子进程之后,有如下特点:
- 父子进程并发执行, 子进程从 fork() 之后开始执行
- 父子进程的执行顺序由操作系统算法决定的,不是由程序本身决定
- 子进程会拷贝父进程地址空间的内容, 包括缓冲区、文件描述符等:当子进程拷贝了父进程文件描述符后,则会共享文件状态标志与文件偏移量等信息
5 进程多任务:
使用 fork() 函数之后,会创建子进程,fork() 之后的代码会在父、子进程中都执行一遍
- 如果父子进程执行相同的任务,则正常执行
- 如果父子进程执行不同的任务,则需要利用 fork() 函数返回值
在创建多个进程时, 最主要的原则为 由父进程统一创建,统一管理, 不要进行递归创建
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
int g_x = 0;
int main(int argc, char **argv)
{
printf("pid=%d;ppid=%d\n", getpid(), getppid());
write(STDOUT_FILENO, "hello", 5); // Linux文件IO,不带缓存区
fputs("hello", stdout); // 标准IO,自带缓冲区
pid_t pid = fork(); // 子进程会复制父进程的缓冲区、文件描述符。因此运行结果会打印三个hello。
if (pid == -1) {
perror("fork error");
return pid;
}
for (int x=0;x<5;x++) {
g_x++;
sleep(0.1);
}
printf("g_x=%d\n", g_x);
return 0;
}
6 进程的退出:
在进程结束时,需要释放进程地址空间 以及内核中产生的各种数据结构。
资源的释放需要通过调用 exit 函数或者 _exit 函数来完成
exit属于标准库函数,_exit为底层系统函数。exit是基于_exit的,即exit最终也会调用_exit,区别在于,exit还会刷新缓冲区,而_exit不会
在程序结束时,会自动调用 exit 函数。exit 函数让当前进程退出, 并刷新缓冲区。所以如无特殊需求,不用显示调用。
并发编程时,父进程一定要等待子进程退出,避免内存里面残留一些孤儿进程
7 进程的等待:
在子进程运行结束后,进入僵死状态, 并释放资源, 子进程在内核中的 数据结构 依然保留。
父进程 调用 wait()
与 waitpid()
函数等待子进程退出后,释放子进程遗留的资源。
示例:
#include <sys/wait.h>
#include <sys/types.h>
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
pid_t pid1 = fork();
if (pid1 < 0) {
perror("fork");
return pid1;
}
if (pid1 > 0) {
pid_t pid2 = fork();
if (pid2 < 0) {
perror("fork");
return pid2;
}
if (pid2 > 0) {
printf("this is father, pid=%d\n", getpid());
int wstatus = 0;
pid_t c_pid;
// c_pid = wait(&wstatus); // 阻塞
//c_pid = waitpid(-1, &wstatus, WNOHANG);
c_pid = waitpid(pid1, &wstatus, WUNTRACED);
if (c_pid < 0) {
perror("wait error");
return -1;
}
while ((c_pid = waitpid(-1, &wstatus, WNOHANG)) == 0) {} // 非阻塞,但是主进程又要等待子进程退出写法。并发编程时,父进程一定要等待子进程退出,避免内存里面残留一些孤儿进程
printf("wstatus=%d, child c_pid=%d\n", WEXITSTATUS(wstatus), c_pid);
} else if (pid2 == 0) {
sleep(5);
printf("this is child2, pid=%d\n", getpid());
exit(EXIT_SUCCESS);
}
}
else if (pid1 == 0) {
sleep(2);
printf("this is child1, pid=%d\n", getpid());
exit(EXIT_SUCCESS);
}
return 0;
}
8 进程的替换:
使用execl函数族,为进程重载0-3G的用户空间,可与fork函数搭配使用。
语法:int execl("绝对路径", “标识符”, “需要的参数”(需要多少传入多少) ,NULL);
博客链接:https://blog.csdn.net/m0_53133879/article/details/125092300
其作用是通过一个进程启动另外一个进程。比如通过c调用执行一个python脚本:
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
int main()
{
int ret;
char *p_path = "/usr/bin/python3";
ret = execl(p_path, "python3", "hello.py", NULL);
// ret = execlp("ls", "my_ls", "-l", NULL);
if (ret == -1) { // 必须有异常处理,否则异常情况,不会做进程替换,导致出现两个执行主进程的程序
perror("execl error");
return ret;
}
printf("this is main process\n");
return 0;
}
2.1 进程间通信
四种方式:管道、信号、消息队列、共享内存。
2.1.1 管道
无名管道pipe,适用于父子关系的进程;有名管道mkfifo,适用于独立的进程之间。
2.1.2 信号
信号是在软件层次上 是一种通知机制, 对中断机制的一种模拟,是一种异步通信方式
信号来源:
- 其他程序发送
- 控制终端发送,如ctrl+c,其实也是其他程序发送的信号
- 程序中设定的定时器产生的
SIGALRM
信号; - 系统错误:内存访问越界、除0运算等
linux系统有64种信号,可以通过kill -l
查看。这些信号都定义在asm-generic/signal.h
头文件中
信号的处理:
- 由进程发送信号
- 由内核将信号投递给具体的进程。
记录进程信息的结构体:struct task_struct
,位于linux/sched.h
头文件中
信号的处理方式:
- 忽略
- 使用linux默认的处理方式
- 程序自定义信号处理函数:需注册到内核中,供内核调用
发送信号的函数:
int kill(pid_t pid, int sig)
,给指定的进程发送信号;int raise(int sig)
,给自己发送信号- 注意,向其他进程发送信号通常需要相应的权限。通常情况下,只有进程的所有者或者 root 用户才能发送信号给进程
signal/kill/raise/abort
函数博客:https://blog.csdn.net/luosuss/article/details/136107989
pause
函数博客:https://blog.csdn.net/m0_61629312/article/details/131992361
信号的处理:
使用signal()
函数:通过该函数,将进程需要自定义处理的信号处理函数注册到内核。当进程收到特定的信号时,调用注册的函数处理信号。
参考博客:https://blog.csdn.net/yockie/article/details/51729774
alarm定时器:
alarm函数给进程设置定时器,定时超时后,内核给进程发送SIGALRM信号。
子进程退出异步通知 :
使用wait
等待子进程退出非常消耗资源,并且在阻塞情况下,父进程不能执行其他逻辑。解决方案:
子进程在退出的时候,会给父进程发送SIGCHLD
信号,因此父进程可以捕获SIGCHLD
来异步等待子进程退出
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
void chld_sig_handler(int sig)
{
printf("receive child signal: %s\n", strsignal(sig));
wait(NULL);
exit(0);
}
int main(int argc, char **argv)
{
__sighandler_t ret = signal(SIGCHLD, chld_sig_handler);
if (ret == SIG_ERR) {
perror("signal");
exit(EXIT_FAILURE);
}
pid_t pid = fork();
if (pid == 0) {
sleep(2);
printf("child exit\n");
exit(EXIT_SUCCESS);
} else if (pid > 0){
while (1);
} else {
perror("fork error");
exit(EXIT_FAILURE);
}
return 0;
}
2.1.3 消息队列
2.1.4 共享内存
查询内存页:getconf PAGE_SIZE
应用场景:
- 进程间共享:经常碰到一种场景,进程需要加载一份配置文件,可能这个文件有100K大,那如果这台机器上多个进程都要加载这份配置文件时,比如有200个进程,那内存开销合计为20M,但如果文件更多或者进程数更多时,这种对内存的消耗就是一种严重的浪费。比较好的解决办法是,由一个进程负责把配置文件加载到共享内存中,然后所有需要这份配置的进程只要使用这个共享内存即可。
- 生产者消费者模式
- 父子进程间通讯
共享内存是将分配的物理空间直接映射到进程的用户虚拟地址空间中, 减少数据在内核空间缓存。注意,共享内存是在用户空间,不用拷贝到内核空间。所以,共享内存是一种效率较高的进程间通讯的方式。
博客:https://cloud.tencent.com/developer/article/1551288
博客:https://help.aliyun.com/zh/ecs/user-guide/smc-instructions
3 线程
在 Linux 系统有很多命令可以查看进程,包括 pidstat 、top 、ps ,可以查看进程,也可以查看一个进程下的线程
- pidstat: -t 显示指定进程所关联的线程; -p 指定 进程 pid:
pidstat -t -p 132
- top 命令查看某一个进程下的线程,需要用到 -H 选项在结合 -p 指定 pid:
top -H -p 132
- ps 命令结合 -T 选项就可以查看某个进程下所有线程:
ps -T -p 132
线程间通信:
- 进程间通信的方法也适用于线程间通信,如:管道、消息队列、共享内存、信号、信号量等
- 父线程参数传给子线程:利用pthread_create的第四个参数
- 子线程给父线程传参:利用
pthread_exit + pthread_join
.
4 IO多路复用
4.1 非阻塞IO
使用fcntl()函数,实现非阻塞IO:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
int main()
{
int flag = fcntl(0, F_GETFL);
flag |= O_NONBLOCK;
if (fcntl(0, F_SETFL, flag) == -1) {
perror("fcntl");
exit(1);
}
char buf[1024] = "\0";
while (scanf("%[^\n]+", buf) <= 0) {
printf("please enter >>\n");
sleep(2);
}
printf("inputs:%s\n", buf);
return 0;
}
4.2 IO多路复用
三种方案:select/poll/epoll
用户进程将需要监控的一组文件描述符集合,拷贝到内核;内核检测文件描述符是否就绪,用户进程被阻塞;
如果内核检测到有就绪的文件描述符,则通知用户进程;用户进程收到通知处理IO
select会将已经就绪的文件描述符直接复制到用户空间传入的文件描述符集合中:
- 因此在非阻塞循环中,用于select的文件描述符集合要做备份处理;
- timeout结构体的值在select返回前,也会被减去消耗的时间,所以timeout也要备份;设置超时时间,当timeout为NULL时,无限期阻塞
- 要判断是不是目标文件描述符在不在集合中,因为返回的可能是其他文件描述符
select底层原理:
1、文件描述符集合:
- 阅读源码可知,文件描述符集合
fd_set
实际上是一个long int __fd_mask[16]
类型的数组。在64位的系统中long int
类型为8位二进制,所以一个文件描述符集合最多存储1024个文件描述符。 fd_set
并不是真的存储每一个文件描述符数字,而是使用位图存储来表示文件描述符的就绪状态;位图中的每一位代表一个文件描述符。- 调用select的时候,select遍历位图,将就绪的文件描述符在位图中的对应位置为就绪状态。
- select在内核空间有三个结果集合
res_in/res_out/res_exp
,select在遍历用户空间拷贝的in/out/exp
集合时,如果对应的文件描述符就绪,则将结果集合中对应的文件描述符置为就绪状态。最后将结果集合拷贝到用户空间。 - 由上可见:select的缺点是遍历、拷贝浪费开销。
poll底层原理:
- 与select相比,poll底层使用链表保存文件描述符,没有文件描述符数量的限制。
- poll原理博客:https://www.cnblogs.com/luof-man/articles/18274834
epoll底层原理:
- 底层使用红黑树保存被检测的文件描述符;使用callback机制通知应用层文件描述符就绪,而不是轮询,提高了效率。
- epoll支撑
边缘触发(edge-triggered)
和水平触发(level-trggered)
两种模式 - 边缘触发与水平触发博客:https://zhuanlan.zhihu.com/p/363353777、https://blog.csdn.net/albertsh/article/details/123958013
- 水平触发会再次进行通知,而边缘触发不会再进行通知。所以,边缘触发需要一次性的把缓冲区的数据读完为止,也就是一直读,直到读到EGAIN为止,EGAIN说明缓冲区已经空了,因为这一点,边缘触发需要设置文件描述符为非阻塞。
//水平触发
ret = read(fd, buf, sizeof(buf));
//边缘触发:一次性读完,直到读到EGAIN为止
while(true) {
ret = read(fd, buf, sizeof(buf);
if (ret == EAGAIN) break;
}
// 边缘触发:设置文件描述符非阻塞
#include <fcntl.h>
setnonblocking(fd);
项目:基于多进程/线程的发布与订阅系统