Linux-A04-进程

基础知识

进程就是一个程序的执行流程,内部保存程序运行所需的资源

在UNIX系统中,直邮fork系统调用才可以创建进程

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t id = fork();
if (id < 0) {
perror("fork\n");
} else if (id == 0) { // 子进程
printf("子进程\n");
} else { // 父进程
printf("父进程\n");
}
return 0;
}

进程创建之后,父子进程都有各自不同的地址空间,其中一个进程在其地址空间的修改对另一个进程不可见。子进程的初始化空间是父进程的一个副本,这里涉及两个不同地址空间,不可写的内存区是共享的,某些UNIX的实现使程序正文在两者间共享,因为它是不可修改的。

还有一种写时复制共享技术,子进程共享父进程的所有内存,一旦两者之一想要修改部分内存,则这块内存被复制确保修改发生在当前进程的私有内存区域

PCB

进程控制块(PCB),操作系统为每个进程都维护一个PCB,用来保存与该进程有关的各种状态信息。进程可以抽象理解为就是一个PCB,PCB是进程存在的唯一标志,用PCB来描述进程的基本情况以及运行变化的过程

PCB包含进程状态的重要信息,包括程序计数器、堆栈指针、内存分配状况、所打开文件的状态、账号和调度信息,以及其它在进程由运行态转换到就绪态或阻塞态时必须保存的信息,从而保证该进程随后能再次启动,就像从未中断过一样。

中断发生后操作系统最底层做了什么?

  1. 硬件压入堆栈程序计数器
  2. 硬件从中断向量装入新的程序计数器
  3. 汇编语言过程保存寄存器的值
  4. 汇编语言过程设置新的堆栈
  5. C中断服务例程运行(典型的读和缓冲输入)
  6. 调度程序决定下一个将运行的进程
  7. C过程返回到汇编代码
  8. 汇编语言过程开始运行新的当前进程

进程控制块中存储的信息

进程标识信息:如本进程的标识,本进程的父进程标识,用户标识等。

处理机状态信息保护区:用于保存进程的运行现场信息:

  • 用户可见寄存器:用户程序可以使用的数据,地址等寄存器
  • 控制和状态寄存器:程序计数器,程序状态字
  • 栈指针:过程调用、系统调用、中断处理和返回时需要用到它

进程控制信息

  • 调度和状态信息:用于操作系统调度进程使用
  • 进程间通信信息:为支持进程间与通信相关的各种标识、信号、信件等,这些信息存在接收方的进程控制块中
  • 存储管理信息:包含有指向本进程映像存储空间的数据结构
  • 进程所用资源:说明由进程打开使用的系统资源,如打开的文件等
  • 有关数据结构连接信息:进程可以连接到一个进程队列中,或连接到相关的其他进程的PCB

进程状态

图片

进程因为等待输入而被阻塞,进程从运行态转换到阻塞态

调度程序选择了另一个进程执行时,当前程序就会从运行态转换到就绪态

被调度程序选择的程序会从就绪态转换到运行态

当阻塞态的进程等待的一个外部事件发生时,就会从阻塞态转换到就绪态,此时如果没有其他进程运行时,则立刻从就绪态转换到运行态!

1
2
3
4
5
6
7
8
9
10
pid=fork(); // 创建一个与父进程一样的子进程
pid=waitpid(); // 等待子进程终止
s=execve(); // 替换进程的核心映像
exit(); // 终止进程运行并返回状态值
s=sigaction(); // 定义信号处理的动作
s=sigprocmask(); // 检查或更换信号掩码
s=sigpending(); // 获得阻塞信号集合
s=sigsuspend(); // 替换信号掩码或挂起进程
alarm(); // 设置定时器
pause(); // 挂起调用程序直到下一个信号出现

进程调度

一个CPU同一时刻只会有一个进程处于运行状态,操作系统通过调度算法选择下一个要运行的进程

什么时候进行调度

  1. 系统调用创建一个新进程后,需要决定是运行父进程还是运行子进程
  2. 一个进程退出时需要做出调度决策,需要决定下一个运行的是哪个进程
  3. 当一个进程阻塞在I/O和信号量或者由于其它原因阻塞时,必须选择另一个进程运行
  4. 当一个I/O中断发生时,如果中断来自IO设备,而该设备现在完成了工作,某些被阻塞的等待该IO的进程就成为可运行的就绪进程了,是否让新就绪的进程运行,或者让中断发生时运行的进程继续运行,或者让某个其它进程运行,这就取决于调度程序的抉择了

调度算法可以分类

  1. 非抢占式调度算法:让进程运行直至被阻塞,或者直到该进程自动释放CPU。在时钟中断发生时不会进行调度,在处理完时钟中断后,如果没有更高优先级的进程等待,则被中断的进程会继续执行。简单来说,调度程序必须等待事件结束。

非抢占方式引起进程调度的条件:

  • 进程执行结束,或发生某个事件而不能继续执行
  • 正在运行的进程因有I/O请求而暂停执行
  • 进程通信或同步过程中执行了某些原语操作(wait、block等)
  1. 抢占式调度算法:进程运行固定时段。如果时段结束时进程仍在运行,就被挂起,而调度程序挑选另一个进程运行,进行抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便CPU控制返回给调度程序,如果没有可用的时钟,那么非抢占式调度就是唯一的选择。简单来说,防止单一进程长时间独占CPU资源。
图片

进程间通信

匿名管道

匿名管道就是pipe,pipe只能在父子进程间通信,且数据是单向流动(半双工通信)

使用方式

  • 父进程创建管道,会得到两个文件描述符,分别指向管道的两端;
  • 父进程创建子进程,从而子进程也有两个文件描述符指向同一管道;
  • 父进程可写数据到管道,子进程就可从管道中读出数据,从而实现进程间通信,下面的示例代码中通过pipe实现了每秒钟父进程向子进程都发送消息的功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main() {
int _pipe[2];
int ret = pipe(_pipe);
if (ret < 0) {
perror("pipe\n");
}
pid_t id = fork();
if (id < 0) {
perror("fork\n");
} else if (id == 0) { // 子进程
close(_pipe[1]);
int j = 0;
char _mesg[100];
while (j < 100) {
memset(_mesg, '\0', sizeof(_mesg));
read(_pipe[0], _mesg, sizeof(_mesg));
printf("%s\n", _mesg);
j++;
}
} else { // 父进程
close(_pipe[0]);
int i = 0;
char *mesg = NULL;
while (i < 100) {
mesg = "父进程来写消息了";
write(_pipe[1], mesg, strlen(mesg) + 1);
sleep(1);
++i;
}
}
return 0;

}

平时也会使用 | 关于管道的命令行,如 ls | less

图片

  • 创建管道
  • 为ls创建一个进程,设置stdout为管道写端
  • 为less创建一个进程,设置stdin为管道读端

高级管道

通过popen将另一个程序当作一个新的进程在当前进程中启动,它算作当前进程的子进程,高级管道只能用在有亲缘关系的进程间通信,这种亲缘关系通常指父子进程,下面的GetCmdResult函数可以获取某个Linux命令执行的结果,实现方式就是通过popen

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
std::string GetCmdResult(const std::string &cmd, int max_size = 10240) {
char *data = (char *)malloc(max_size);
if (data == NULL) {
return std::string("malloc fail");
}
memset(data, 0, max_size);
const int max_buffer = 256;
char buffer[max_buffer];
// 将标准错误重定向到标准输出
FILE *fdp = popen((cmd + " 2>&1").c_str(), "r");
int data_len = 0;

if (fdp) {
while (!feof(fdp)) {
if (fgets(buffer, max_buffer, fdp)) {
int len = strlen(buffer);
if (data_len + len > max_size) {
cout << "data size larger than " << max_size;
break;
}
memcpy(data + data_len, buffer, len);
data_len += len;
}
}
pclose(fdp);
}
std::string ret(data, data_len);
free(data);
return ret;
}

命名管道

匿名管道有个缺点就是通信的进程一定要有亲缘关系,而命名管道就不需要这种限制。

命名管道其实就是一种特殊类型的文件,所谓的命名其实就是文件名,文件对各个进程都可见,通过命名管道创建好特殊文件后,就可以实现进程间通信。

通过mkfifo创建一个特殊的类型的文件,参数读者看名字应该就了解,一个是文件名,一个是文件的读写权限:

1
int mkfifo(const char* filename, mode_t mode);

当返回值为0时,表示该命名管道创建成功,至于如何通信,其实就是个读写文件的问题

消息队列

队列想必大家都知道,像FIFO一样,这里可以有多个进程写入数据,也可以有多个进程从队列里读出数据,但消息队列有一点比FIFO还更高级,它读消息不一定要使用先进先出的顺序,每个消息可以赋予类型,可以按消息的类型读取,不是指定类型的数据还存在队列中。本质上MessageQueue是存放在内核中的消息链表,每个消息队列链表会由消息队列标识符表示,这个消息队列存于内核中,只有主动的删除该消息队列或者内核重启时,消息队列才会被删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// 创建和访问一个消息队列
int msgget(key_t, key, int msgflg);
// 用来把消息添加到消息队列中
int msgsend(int msgid, const void *msg_ptr, size_t msg_sz, int msgflg);
// msg_ptr是结构体数据的指针,结构第一个字段要有个类型:struct Msg {
long int message_type;
// 想要传输的数据
};
// 从消息队列中获取消息
int msgrcv(int msgid, void *msg_ptr, size_t msg_st, long int msgtype, int msgflg);
// 用来控制消息队列,不同的command参数有不同的控制方式
int msgctl(int msgid, int command, struct msgid_ds *buf);

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/msg.h>

#include <chrono>
#include <iostream>
#include <thread>

using namespace std;

#define BUFFER_SIZ 20

typedef struct {
long int msg_type;
char text[BUFFER_SIZ];
} MsgWrapper;

void Receive() {
MsgWrapper data;
long int msgtype = 2;
int msgid = msgget((key_t)1024, 0666 | IPC_CREAT);
if (msgid == -1) {
cout << "msgget error \n";
return;
}
while (true) {
if (msgrcv(msgid, (void *)&data, BUFFER_SIZ, msgtype, 0) == -1) {
cout << "error " << errno << endl;
}
cout << "read data " << data.text << endl;
if (strlen(data.text) > 6) { // 发送超过6个字符的数据,结束
break;
}
}
if (msgctl(msgid, IPC_RMID, 0) == -1) {
cout << "msgctl error \n";
}
cout << "Receive ok \n";
}

void Send() {
MsgWrapper data;
long int msgtype = 2;
int msgid = msgget((key_t)1024, 0666 | IPC_CREAT);
if (msgid == -1) {
cout << "msgget error \n";
return;
}
data.msg_type = msgtype;
for (int i = 0; i < 10; ++i) {
memset(data.text, 0, BUFFER_SIZ);
char a = 'a' + i;
memset(data.text, a, 1);
if (msgsnd(msgid, (void *)&data, BUFFER_SIZ, 0) == -1) {
cout << "msgsnd error \n";
return;
}
std::this_thread::sleep_for(std::chrono::seconds(1));
}
memcpy(data.text, "1234567", 7);
if (msgsnd(msgid, (void *)&data, BUFFER_SIZ, 0) == -1) {
cout << "msgsnd error \n";
return;
}
}

int main() {
std::thread r(Receive);
r.detach();
std::thread s(Send);
s.detach();
std::this_thread::sleep_for(std::chrono::seconds(20));
return 0;
}
  • 消息队列收发消息自动保证了同步,不需要由进程自己来提供同步方法,而命名管道需要自行处理同步问题

  • 消息队列接收数据可以根据消息类型有选择的接收特定类型的数据,不需要像命名管道一样默认接收数据。

  • 消息队列有一个缺点就是发送和接收的每个数据都有最大长度的限制

共享内存

可开辟中一块内存,用于各个进程间共享,使得各个进程可以直接读写同一块内存空间,就像线程共享同一块地址空间一样,该方式基本上是最快的进程间通信方式,因为没有系统调用干预,也没有数据的拷贝操作,但由于共享同一块地址空间,数据竞争的问题就会出现,需要自己引入同步机制解决数据竞争问题。

共享内存只是一种方式,它的实现方式有很多种,主要的有mmap系统调用、Posix共享内存以及System V共享内存等。通过这三种“工具”共享地址空间后,通信的目的自然就会达到

信号量

  • 信号量semaphore,是操作系统中一种常用的同步与互斥的机制;
  • 信号量允许多个进程(计数值>1)同时进入临界区;
  • 如果信号量的计数值为1,一次只允许一个进程进入临界区,这种信号量叫二值信号量;
  • 信号量可能会引起进程睡眠,开销较大,适用于保护较长的临界区;
  • 与读写自旋锁类似,linux内核也提供了读写信号量的机制;
流程分析

可以将信号量比喻成一个盒子,初始化时在盒子里放入N把钥匙,钥匙先到先得,当N把钥匙都被拿走完后,再来拿钥匙的人就需要等待了,只有等到有人将钥匙归还了,等待的人才能拿到钥匙

1
2
3
4
5
6
7
8
9
10
11
struct semaphore {
raw_spinlock_t lock; //自旋锁,用于count值的互斥访问
unsigned int count; //计数值,能同时允许访问的数量,也就是上文中的N把锁
struct list_head wait_list; //不能立即获取到信号量的访问者,都会加入到等待列表中
};

struct semaphore_waiter {
struct list_head list; //用于添加到信号量的等待列表中
struct task_struct *task; //用于指向等待的进程,在实际实现中,指向current
bool up; //用于标识是否已经释放
};
image-20210714000530577

down接口用于获取信号量

  • 如果sem->count > 0时,也就是盒子里边还有多余的锁,直接自减并返回了
  • sem->count == 0时,表明盒子里边的锁被用完了,当前任务会加入信号量的等待列表中,设置进程的状态,并调用schedule_timeout来睡眠指定时间,实际上这个时间设置的无限等待,也就是只能等着被唤醒,当前任务才能继续运行;

up用于释放信号量

  • 如果等待列表为空,表明没有多余的任务在等待信号量,直接将sem->count自加即可。
  • 如果等待列表非空,表明有任务正在等待信号量,那就需要对等待列表中的第一个任务(等待时间最长)进行唤醒操作,并从等待列表中将需要被唤醒的任务进行删除操作
信号量缺点
  • SemaphoreMutex在实现上有一个重大的区别:ownershipMutex被持有后有一个明确的owner,而Semaphore并没有owner,当一个进程阻塞在某个信号量上时,它没法知道自己阻塞在哪个进程(线程)之上;

  • 没有ownership会带来以下几个问题:

    1. 在保护临界区的时候,无法进行优先级反转的处理;
    2. 系统无法对其进行跟踪断言处理,比如死锁检测等;
    3. 信号量的调试变得更加麻烦;

因此,在Mutex能满足要求的情况下,优先使用Mutex

其他接口

信号量提供了多种不同的信号量获取的接口,介绍如下:

1
2
3
4
5
6
7
8
/* 未获取信号量时,进程轻度睡眠:TASK_INTERRUPTIBLE */
int down_interruptible(struct semaphore *sem)
/* 未获取到信号量时,进程中度睡眠:TASK_KILLABLE */
int down_killable(struct semaphore *sem)
/* 非等待的方式去获取信号量 */
int down_trylock(struct semaphore *sem)
/* 获取信号量,并指定等待时间 */
int down_timeout(struct semaphore *sem, long timeout)
读写信号量

读写自旋锁,读写信号量的功能类似,它能有效提高并发性,包含以下特点:

  • 允许多个读者同时进入临界区
  • 读者与写者不能同时进入临界区(读者与写者互斥)
  • 写者与写者不能同时进入临界区(写者与写者互斥)

读写信号量的数据结构与信号量的结构比较相似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct rw_semaphore {
atomic_long_t count; //用于表示读写信号量的计数
struct list_head wait_list; //等待列表,用于管理在该信号量上睡眠的任务
raw_spinlock_t wait_lock; //锁,用于保护count值的操作
#ifdef CONFIG_RWSEM_SPIN_ON_OWNER
struct optimistic_spin_queue osq; /* spinner MCS lock */ //MCS锁,参考上一篇文章Mutex中的介绍
/*
* Write owner. Used as a speculative check to see
* if the owner is running on the cpu.
*/
struct task_struct *owner; //当写者成功获取锁时,owner会指向锁的持有者
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};

读写自旋锁,读写自旋锁中的lock字段,bit[31]用于写锁的标记,bit[30:0]用于读锁的统计,而读写信号量的count字段也大体类似;

孤儿进程与僵尸进程

在linux中,正常情况下子进程是通过父进程创建的,父进程无法预测子进程结束时间,需要调用 wait/waitpid 系统调用取的子进程的终止状态

孤儿进程

如果父进程先退出,而它的子进程还在运行,那么子进程被称为孤儿进程。孤儿进程被根进程(进程号为1)所收养,由根进程管理

僵尸进程

任何一个子进程(根进程除外)在退出之后,并非马上消失,内核释放该进程资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息

  • 进程号 the process ID
  • 退出状态 the Termination status of the process
  • 运行时间 the amount of CPU time taken by the process

留下一个称为 僵尸进程(Zombie)的数据结构,直到父进程通过 wait/waitpid 来取时才会释放。如果父进程不调用 wait/waitpid 的话,那么保留的信息就不会释放,其进程号就会一直被占用,但系统所使用的进程号时有限的,如果存在大量僵尸进程,可能因为没有可用进程号导致系统不能产生新的进程

  1. 通过命令 ulimit -a 查看 max user processes
image-20210731154242489
  1. 通过 ps 命令查看,状态为 Z

    image-20210731154402636
  2. 定位僵尸进程

    ps -A -ostat,ppid,pid,cmd |grep -e '^[Zz]’

    -A 参数列出所有进程

    -o 自定义输出字段 stat(状态)、ppid(进程父id)、pid(进程id)、cmd(命令)

    因为状态为z或者Z的进程为僵尸进程,所以我们使用grep抓取stat状态为zZ进程

    image-20210731154627963
    • 僵尸进程ID:3457,父进程ID:3425

    • 僵尸进程ID:3533,父进程ID:3511

  3. 处理僵尸进程

    使用 kill -HUP <僵尸进程ID> 往往无法杀死僵尸进程,-hup会让进程挂起/休眠。此时需要使用 kill -HUP <僵尸进程父ID> 来杀死进程(注意这里有可能杀死根进程,杀的时候注意)

    杀死后可通过上述查询命令查看僵尸进程是否存在

  4. 僵尸进程代码解决

    方法一:wait/waitpid 函数: pid_t wait(int *status)

    父进程在调用wait函数之后将自己阻塞,由wait自动分析是否当前进程的某个子进程已经退出

    如果找到了变成僵尸的子进程,wait就会收集这个子进程信息,并将它测底销毁后返回

    如果没有找到,wait就会一直阻塞在这里,直到有一个出现为止。其中参数 status 用来保存被收集进程退出时的一些状态,是一个指向int类型的指针(不关心如何死掉,直接使用 wait(NULL) ),一个wait也只能处理一个僵尸进程

    方法2:将父进程中对SIGCHILD信号的处理函数设为

    使用信号,引入 sinal.h,子进程退出时向父进程发送SIGCHILD信号,父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程(SIG_IGN 或直接忽略)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
     1 #include <stdio.h>
    2 #include <unistd.h>
    3 #include <errno.h>
    4 #include <stdlib.h>
    5 #include <signal.h>
    6
    7 static void sig_child(int signo);
    8
    9 int main()
    10 {
    11 pid_t pid;
    12 //创建捕捉子进程退出信号
    13 signal(SIGCHLD,sig_child);
    14 pid = fork();
    15 if (pid < 0)
    16 {
    17 perror("fork error:");
    18 exit(1);
    19 }
    20 else if (pid == 0)
    21 {
    22 printf("I am child process,pid id %d.I am exiting.\n",getpid());
    23 exit(0);
    24 }
    25 printf("I am father process.I will sleep two seconds\n");
    26 //等待子进程先退出
    27 sleep(2);
    28 //输出进程信息
    29 system("ps -o pid,ppid,state,tty,command");
    30 printf("father process is exiting.\n");
    31 return 0;
    32 }
    33
    34 static void sig_child(int signo)
    35 {
    36 pid_t pid;
    37 int stat;
    38 //处理僵尸进程
    39 while ((pid = waitpid(-1, &stat, WNOHANG)) >0)
    40 printf("child %d terminated.\n", pid);
    41 }

    **方法3:**将子进程成为孤儿进程,从而其父进程变为根进程,通过根进程处理子进程

    进程P 创建子进程 Pc1 之后,子进程再创建子进程 Pcc1, 父进程等待第一个子进程 Pc1退出,让Pc1退出,Pcc1 处理问题称为孤儿进程,由根进程处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <errno.h>

    int main()
    {
    pid_t pid;
    //创建第一个子进程
    pid = fork();
    if (pid < 0)
    {
    perror("fork error:");
    exit(1);
    }
    //第一个子进程
    else if (pid == 0)
    {
    //子进程再创建子进程
    printf("I am the first child process.pid:%d\tppid:%d\n",getpid(),getppid());
    pid = fork();
    if (pid < 0)
    {
    perror("fork error:");
    exit(1);
    }
    //第一个子进程退出
    else if (pid >0)
    {
    printf("first procee is exited.\n");
    exit(0);
    }
    //第二个子进程
    //睡眠3s保证第一个子进程退出,这样第二个子进程的父亲就是根进程里
    sleep(3);
    printf("I am the second child process.pid: %d\tppid:%d\n",getpid(),getppid());
    exit(0);
    }
    //父进程处理第一个子进程退出
    if (waitpid(pid, NULL, 0) != pid) //等待进程ID为 pid 的子进程,只要子进程没有结束,就一直等下去
    {
    perror("waitepid error:");
    exit(1);
    }
    exit(0);
    return 0;
    }

参考文献

  1. https://www.cnblogs.com/Anker/p/3271773.html
  2. https://mp.weixin.qq.com/s/wTicQwTu8Ta8gLv2fZ3rCA
  3. https://mp.weixin.qq.com/s/Lu1nqXfrGPsFcFHH_R_rYA