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不会再检查有没有中断,从而实现操作原子性。