Linux学习笔记(三)----进程

简介

进程是系统资源分配的最小单位,它曾经也是CPU调度的最小单位,但后面被线程所取代。

进程树

Linux系统通过父子进程关系串联起来,所有进程之前构成了一个多叉树结构。

孤儿进程

孤儿进程是指父进程已经结束,子进程还在执行的进程。那么此时此刻,该进程就变成了孤儿进程。

当进程变成孤儿进程后,系统会认领该进程,并为他再分配一个父进程(就近原则,爸爸的爸爸,爸爸的爸爸的爸爸)。

》当孤儿进程被认领后,就很难再进行标准准入输出对其停止了。因为切断了跟终端的联系,所以编码过程中要尽量避免出现孤儿进程

进程通讯(inter -Process Communication,IPC)

多个进程之间的内存相互隔离,多个进程之间通讯方式有如下几种:

管道(pipe)

匿名管道

半双工通信,即数据只能在一个方向上流动。只能在具有父子关系的进程之间使用。

其原理为:内核在内存中创建一个缓冲区,写入端将数据写入缓冲区,读取端从缓冲区中读取数据
点击查看代码

复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main() {
    int pipefd[2];
    pid_t pid;
    char buffer[100];

    // 创建管道
    if (pipe(pipefd) == -1) {
        perror("pipe");
        return 1;
    }

    // 创建子进程
    pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        // 子进程:关闭写端,从管道读取数据
        close(pipefd[1]);
        read(pipefd[0], buffer, sizeof(buffer));
        printf("Child process received: %s\n", buffer);
        close(pipefd[0]);
    } else {
        // 父进程:关闭读端,向管道写入数据
        close(pipefd[0]);
        const char *message = "Hello, child process!";
        write(pipefd[1], message, strlen(message) + 1);
        close(pipefd[1]);
    }

    return 0;
}

命名管道

可以在任意两个进程之间进行通信,不要求进程具有亲缘关系;遵循先进先出(FIFO)原则。

其原理为:在文件系统中创建一个特殊的文件,进程通过读写这个文件来进行通信,因此文件读取是从头开始读,所以先进先出。
点击查看代码

复制代码
// 写进程
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>

#define FIFO_NAME "myfifo"

int main() {
    int fd;
    const char *message = "Hello, named pipe!";

    // 创建命名管道
    mkfifo(FIFO_NAME, 0666);

    // 打开命名管道进行写操作
    fd = open(FIFO_NAME, O_WRONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    // 向命名管道写入数据
    write(fd, message, strlen(message) + 1);
    close(fd);

    return 0;
}

// 读进程
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#define FIFO_NAME "myfifo"

int main() {
    int fd;
    char buffer[100];

    // 打开命名管道进行读操作
    fd = open(FIFO_NAME, O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    // 从命名管道读取数据
    read(fd, buffer, sizeof(buffer));
    printf("Received: %s\n", buffer);
    close(fd);

    // 删除命名管道
	// 理论上,命名管道是可以重复使用的,其本质就是操作文件而已。只是不建议这么操作。
    unlink(FIFO_NAME);

    return 0;
}

共享内存(Shared Memory)

多个进程共享同一块内存地址,是最快的IPC方式。

其原理为:内核在物理内存中分配一块内存区域,多个进程将内存地址映射到自己的虚拟空间内,从而可以直接读写该区域。

点击查看代码

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <string.h>

int main(int argc, char const *argv[])
{
    //创建一个共享内存对象
    char shm_name[100]={0};
    sprintf(shm_name,"/letter%d",getpid());
    int fd= shm_open(shm_name,O_RDWR|O_CREAT,0664);
    if(fd<0){
        perror("shm_open");
        exit(EXIT_FAILURE);
    }

    //给共享内存对象设置大小
    ftruncate(fd,1024);

    //内存映射
    char *share= mmap(NULL,1024,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
    if(share==MAP_FAILED){
        perror("mmap");
        exit(EXIT_FAILURE);
    }

    //映射完成,关闭fd连接
    close(fd);

    //使用内存,完成进程通讯
    pid_t pid=fork();
    if(pid<0){
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if(pid==0){
        //子进程
        strcpy(share,"子进程,嘿嘿嘿嘿。");
    }
    else{
        //父进程
        waitpid(pid,NULL,0);
        printf("收到子进程的信息:%s",share);


    }

    //释放映射
    munmap(share,1024);

    //释放共享内存对象
    shm_unlink(shm_name);
    return 0;
}

临时文件系统

linux的临时文件系统是一种基于内存的文件系统,它将数据存储在RAM中或者SWAP中,共享对象同样也是挂在在临时文件系统中。

我们可以写一段不释放的代码,来眼见为实。
点击查看代码

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <string.h>

int main(int argc, char const *argv[])
{
    char sh_name[100]={0};
    sprintf(sh_name,"/letter%d",getpid());
    int fd=shm_open(sh_name,O_CREAT,0664);
    if(fd<0){
        perror("shm open");
        exit(EXIT_FAILURE);
    }
    while (1)
    {
        //代码空转,方便查看内存区域。
    }
    
    return 0;
}

代码执行中:生成的临时文件

执行后,临时文件也不会消失,因为没有释放。

消息队列(message queue)

消息队列是消息的链表,存放在内核中,由消息队列标识符标识。进程可以向队列中添加消息,也可以从队列中读取消息。

其原理为:内核为每个消息队列维护一个消息链表,消息可以按照不同的类型进行分类,进程可以根据消息类型有选择地读取消息。


生产者

复制代码
#include <fcntl.h>
#include <sys/stat.h>
#include <mqueue.h>
#include <stdio.h>
#include <time.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>


int main()
{
    struct  mq_attr attr;
    attr.mq_flags=0;
    attr.mq_maxmsg=10;
    attr.mq_msgsize=100;
    attr.mq_flags=0;
    attr.mq_curmsgs;

    struct timespec time_info;

    //创建消息队列
    char * mq_name="/p_c_mq";
    mqd_t mqdes=mq_open(mq_name,O_RDWR|O_CREAT,0664,&attr);
    if(mqdes==(mqd_t)-1){
        perror("mq_open");
        exit(EXIT_FAILURE);
    }
    //从控制台接受数据,并发送给消费者
    char write_buff[100];
    while (1)
    {
        memset(write_buff,0,100);
        ssize_t read_count= read(STDIN_FILENO,write_buff,100);
        clock_gettime(0,&time_info);
        time_info.tv_sec+=5;

        if(read_count==-1){
            perror("read");
            continue;
        }
        else if(read_count==0)
        {
            printf("控制台停止发送消息");
            char eof=EOF;
            if(mq_timedsend(mqdes,&eof,1,0,&time_info)==-1){
                perror("mq_timedsend");
            }
            break;
        }
        else{
            if(mq_timedsend(mqdes,write_buff,strlen(write_buff),0,&time_info)==-1){
                perror("mq_timedsend");
            }
            printf("从命令行接受到数据,并发送给消息的队列。");
        }
    }
    //关闭资源
    close(mqdes);
    //由消费者关闭更加合适,否则消费者可能无法接收到最后一条消息。
    //unlink(mq_unlink);
    return 0;
}

消费者

复制代码
#include <fcntl.h>
#include <sys/stat.h>
#include <mqueue.h>
#include <stdio.h>
#include <time.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>


int main()
{
    struct  mq_attr attr;
    attr.mq_flags=0;
    attr.mq_maxmsg=10;
    attr.mq_msgsize=100;
    attr.mq_flags=0;
    attr.mq_curmsgs;

    struct timespec time_info;

    //创建消息队列
    char * mq_name="/p_c_mq";
    mqd_t mqdes=mq_open(mq_name,O_RDWR|O_CREAT,0664,&attr);
    if(mqdes==(mqd_t)-1){
        perror("mq_open");
        exit(EXIT_FAILURE);
    }
    //从控制台接受数据,并发送给消费者
    char read_buff[100];
    while (1)
    {
        memset(read_buff,0,100);
        clock_gettime(0,&time_info);
        time_info.tv_sec+=5;
        //读取数据
        if(mq_timedreceive(mqdes,read_buff,100,NULL,&time_info)==-1){
            perror("mq_timedreceive");
        }
        //判断是否结束
        else if(read_buff[0]==EOF){
            printf("接收到结束信息,准备退出....");
            break;
        }
        else{
            printf("接受到来自生产者的信息%s\n",read_buff);
        }
    }
    //关闭资源
    close(mqdes);
    unlink(mq_unlink);
    return 0;
}

临时文件

与内存共享类似,Linux底层也会生成一个临时文件来代表队列。

三者之间的演化关系

在操作系统的发展过程中,最早出现的是管道通信,用于父子进程之间的信息通信。

不足:

  1. 单向且受限于亲缘关系限制
    只能在父子进程之间传递,后面又演化出了命名管道,解决了亲缘关系的限制。
  2. 传输效率低
    依赖内核缓冲区,且内核空间有限,无法传输大数据。
  3. 缺乏消息分类与异步支持

然后又演化出共享内存,解决了管道在大数据传输的效率问题。成为现在操作系统中最高效的IPC方式,

不足:

  1. 需要开发者自行处理同步逻辑。

经常听到的Zero Copy也是这个原理。

与共享内存同时推出的还有消息队列,它解决了管道/共享内存在功能上的局限性。

消息可以按照分组来选择性的接收,由内核来处理同步逻辑,并对外提供原子操作。

不足:

  1. 性能不如共享内存
    因为消息队列与管道一样,需要从内核复制数据。

消息队列是对管道的功能增强,共享内存是对管道的性能增强。

机制 是否复制内核 存储位置 描述
管道 是(两次复制) 内核缓冲区 基于字节流,需内核复制数据,效率较低,但实现简单
共享内存 否 内核物理地址 直接访问,无数据复制,效率最高,但需同步机制
消息队列 是(两次复制) 内核物理地址 消息按类型组织,支持异步通信,但需内核复制数据,适合中小数据量传输

进程模型

内核会为每一个进程创建并保存一个名为PCB(Process Control Block)的数据结构,来维护进程运行过程中的一些关键信息。

  1. PID

  2. 进程状态

  3. 进程切换时需要保存和回复的寄存器的值

  4. 内存管理
    当前进程所属哪一块内存,如页表,段表等

  5. 当前工作目录

  6. 进程调度信息
    比如优先级,进程调度指针

  7. I/O状态信息
    最常见的就是文件描述符表(struct fdtable),I/O设备列表

  8. 同步和通讯信息
    比如信号量,信号等。

  9. 权限管理
    比如所属id与所属group

    struct task_struct {
    /* 进程状态 */
    volatile long state;

    复制代码
     /* 进程标识符 */
     pid_t pid;
    
     /* 指向父进程的指针 */
     struct task_struct __rcu *parent;
    
     /* 进程的用户和组信息 */
     uid_t uid, euid, suid, fsuid;
     gid_t gid, egid, sgid, fsgid;
    
     /* 其他成员... */

    };

内存模型

内存模型,基本都大同小异,不再赘述.

以C语言程序为例:

栈指针 SP,表示栈顶

帧指针 BP,表示栈底

命令指针 IP,命令指针,指向下一条要运行的命令

进程状态模型

对于进程状态,有一个抽象的定义。

  1. 初始状态(initial)
    进程刚被创建的初始态,该阶段,操作系统会为进程分配资源
  2. 就绪态(ready)
    进程已经准备就绪,当并未被CPU所调度。
  3. 运行态(Running)
    正在CPU上执行代码
  4. 阻塞态(Blocked)
    进程等待其他事件完成,比如网络I/O,磁盘I/O而无法继续时,就处于阻塞状态
  5. 终止态(Final)
    进程执行完毕,准备释放其占用的资源,PCB信息依旧存在。该状态理论上非常短暂。
  6. 僵尸态(Zombie)
    与终止态非常相似,唯一的区别就是因为未知原因PCB长期不释放。

而在Linux中,并未完全遵循上述抽象概念。

不过总体类似,状态主要有如下几种。

  1. D
    不可中断的睡眠状态,比如执行IO操作时,不可中断。
  2. I
    空闲的内核线程
  3. R
    Runnig/Read 运行中或者可以运行的状态
  4. S
    可中断的睡眠状态,比如等待唤醒
  5. T
    由工作控制信号停止
  6. t
    由调试器停止
  7. W
    分页,从2.6内核版本后就不再有效
  8. X
    死亡状态,理论上不会看到,因为相当于整个进程都被回收了,包括PCB,是现实不了的。你如何显示一个不存在的进程?
  9. Z
    僵尸进程,已经终止但PCB尚未被回收
抽象 Linux实现
初始态 N/A
就绪态 R
运行态 R
阻塞态 D,S,T,t
僵尸态 Z

进程状态控制

当一个进程状态变换的时候,通常需要三步。

  1. 找到PCB
  2. 设置PCB状态信息
  3. 将PCB移到响应的队列
    比如进程从阻塞态变成就绪态,状态变化后,CPU的调度队列也要变化。

思考一个问题,如果在第二步的时候,突然来了一个中断,导致第三步没有执行。破坏了原子性,从而使得程序出现异常,这时候应该怎么处理?

汇编每执行一行代码,CPU都会检查一次有没有中断。

这个时候,就要依靠特权指令来实现原子性了

cpu提供两个特权指令

  1. 关中断指令
  2. 开中断指令

因此,在开/关中断指令中间的汇编代码,CPU不会再检查有没有中断,从而实现操作原子性。