重生之我是操作系统(三)----进程&线程

简介

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

进程树

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

孤儿进程

孤儿进程是指父进程已经结束,子进程还在执行的进程。那么此时此刻,该进程就变成了孤儿进程。
当进程变成孤儿进程后,系统会认领该进程,并为他再分配一个父进程(就近原则,爸爸的爸爸,爸爸的爸爸的爸爸)。

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

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

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

管道(pipe)

image

匿名管道

半双工通信,即数据只能在一个方向上流动。只能在具有父子关系的进程之间使用。
其原理为:内核在内存中创建一个缓冲区,写入端将数据写入缓冲区,读取端从缓冲区中读取数据

点击查看代码
#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方式。
其原理为:内核在物理内存中分配一块内存区域,多个进程将内存地址映射到自己的虚拟空间内,从而可以直接读写该区域。
image

点击查看代码
#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;
}

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

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

消息队列(message queue)

消息队列是消息的链表,存放在内核中,由消息队列标识符标识。进程可以向队列中添加消息,也可以从队列中读取消息。
其原理为:内核为每个消息队列维护一个消息链表,消息可以按照不同的类型进行分类,进程可以根据消息类型有选择地读取消息。

image

生产者
#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底层也会生成一个临时文件来代表队列。
image

三者之间的演化关系

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

  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 {

	 /* 执行环境的必要信息 */
    struct thread_info thread_info;
	/* 内核栈 */
	void *stack;
    /* 进程状态 */
    volatile long state;

    /* 进程标识符 */
    pid_t pid;

    /* 指向父进程的指针 */
    struct task_struct __rcu *parent;

    /* 进程的用户和组信息 */
    uid_t uid, euid, suid, fsuid;
    gid_t gid, egid, sgid, fsgid;
	
	/* 文件系统信息 */
	struct fs_struct *fs;
	
	/* 内存管理信息 */
	struct fs_struct *mm;
	
    /* 其他成员... */
};

image

进程创建过程

上面说到,在Linux中,进程PCB实现的是task_struct的实例。

fork

image

fork创建子进程流程如下

  1. 为子进程创建内核栈,thread_info实例
  2. 引用复制父进程的所有信息
    注意:此时只是复制了资源的引用
  3. 清除子进程的统计信息,更新子进程标志位
    因为是复制父进程的,所以还要"刷一遍“数据
  4. 为子进程分配新的PID,并更新PPID
  5. 清除与fork()返回值相关的寄存器
    使得子进程中的fork返回值为0,就是为什么pid=0代表是子进程的原因
  6. 值复制,文件描述符,文件信息系统,内存信息等。
    主要目的是为了引用计数+1
  7. 修改子进程状态
    处于就绪态

execve

image

execve创建子进程流程如下

  1. 在用户态检查基本参数与运行环境
  2. 进入内核态,创建新的内核映射
    清除当前的进程的代码,堆栈,数据,建立一个全新的代码,堆栈,数据。
    因为只清用户空间,不清理内核空间。所以PID不会产生变化。
  3. 初始化上下文
    比如程序计数器(IP),栈指针(SP),各种内核资源,文件描述符等。
  4. 更新堆栈,环境变量等参数
  5. 执行新程序

写时复制(COW)

为了提高进程的创建效率,初始化时子进程会"引用"父进程的资源,只有当两者之一执行了写入操作,才会真正的复制写入区域的内容。为父子进程维护不同的内存地址。

fork与execve的联系

fork():用于创建一个新的进程,这个新进程是调用 fork() 的进程(父进程)的副本,拥有与父进程几乎相同的代码、数据和堆栈等资源。fork() 调用会返回两次,在父进程中返回子进程的进程 ID,在子进程中返回 0

execve():用于在当前进程的上下文中执行一个新的程序,它会用新程序的代码、数据和堆栈替换当前进程的相应部分,从而使当前进程开始执行新的程序。execve() 调用成功后不会返回,除非调用失败。

单独使用 fork() 只能创建一个与父进程几乎相同的子进程,子进程会继续执行与父进程相同的代码。而单独使用 execve() 会直接在当前进程中执行新程序,覆盖当前进程的执行内容。为了创建一个新的进程并在该进程中执行新的程序,通常会先调用 fork() 创建一个子进程,然后在子进程中调用 execve() 来执行新程序,这样既保留了父进程的执行流程,又能在子进程中执行不同的程序

进程组

进程组ID(Process Group ID,PGID),在Liunx中用来标识多个进程的集合,当使用父进程创建子进程时,它们默认就会是同一个进程组。

image

在windows下没有此概念,类似是job object。但在概念上不一致。

内存模型

内存模型,基本都大同小异,不再赘述.
以C语言程序为例:
image

栈指针 SP,表示栈顶
帧指针 BP,表示栈底
命令指针 IP,命令指针,指向下一条要运行的命令

进程状态模型

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

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

image

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

进程切换

懒得再写一遍了,参考此文。
https://www.cnblogs.com/lmy5215006/p/18556052

简单描述一下Linux进程切换过程。

  1. 触发中断
    注意是异步,当中断触发时,会让被中断的进程执行完当前执行,保证原子性。
  2. CPU暂存寄存器的值
  3. 栈指针(SP)指向内核态
    因为进程切换是内核级操作,无法在用户态完成。
  4. CPU将暂存的寄存器的值压栈
  5. CPU将终端编码(error code)的值压栈
  6. 弹栈,挪动程序计数器(ip)指向中断处理程序
    发现中断类型=时钟触发进程切换。
  7. 保存寄存器的值到老进程的PCB中
  8. 恢复新进程的PCB
  9. 栈指针(SP)指向新进程
  10. 老进程回到CPU调度队列
  11. 新进程进行必要的权限检擦
  12. 恢复新进程的cs,ip寄存器
  13. 恢复新进程的状态寄存器
  14. 恢复新进程的栈指针(SP)

为什么要引入线程

long long year ago,系统中的程序只能串行执行。为了解决程序并发执行的问题,操作系统引入了进程
随着软件的发展,有的时候进程需要“同时”做很多事,比如QQ,你可能在视频聊天的同时,还要打字,同时还要传输文件。
由于在进程内部,代码同样也是串行执行,这就导致了上述场景在进程这个维度中,就需要同时启动多个进程来满足需求。

但多个进程的切换会导致系统开销很大,比如PCB的上下文切换,PCB是一个很大的数据结构,内容很多,这对操作系统而言开销并不低。

因此,为了降低进程切换的开销,操作系统引入了线程,在引入线程后,线程成了CPU调度的最小单位。进程只作为资源分配的最小单元
线程也可以理解为"轻量级进程",它的Thread Contral Block, TCB相对PCB来说,瘦身了很多。且如果在同一进程的线程切换,不需要切换进程,所以开销更低。

多线程模型

一对一模型,一个用户线程映射到一个内核线程

  1. 优点
    各个线程可以真正的并行(多核心),单个线程阻塞不影响其它线程
  2. 缺点
    每一个线程的创建于切换都需要内核参与(用户态=>内核态=>用户态,2次切换),开销高。
  3. 应用
    Linux、Windows 等现代操作系统。

多对一模型,多个用户线程映射到一个内核线程

  1. 优点
    线程切换在用户态完成,开销低,速度块。
  2. 缺点
    当内核线程阻塞时,与之关联的用户态线程全部挂起
    难以利用多核处理器
  3. 应用
    应用时间最早,比如go语言的协程,C#的Async

多对多模型,用户线程动态映射到多个内核线程

  1. 优点
    上面两种模型的折中,平衡性能于效能。
    支持多核心
  2. 缺点
    实现比较复杂
  3. 应用
    应用时间最早,比如JAVA的虚拟线程。

Linux中的线程

在Linux中的,线程被当作特殊的进程。也可以称为轻量级进程。两者都有独立的task_struct结构体,并且fork()和pthread_create()底层都是使用系统调用clone()来创建。

  1. 创建进程
    当我们调用fork()时,等同于调用clone(SIGCHLD,0)。
    SIGCHLD:是一个信号,当子进程终止、停止或继续运行时,父进程会收到这个信号。
  2. 创建线程
    pthread_create()时,等同于调用clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND,0)。
    CLONE_VM:此标志表示子进程和父进程共享相同的内存空间
    CLONE_FS:该标志意味着子进程和父进程共享文件系统信息
    CLONE_FILES:此标志表明子进程和父进程共享打开的文件描述符表
    CLONE_SIGHAND:该标志表示子进程和父进程共享信号处理函数表

PID,TGID,TID

因从,从上面的角度来看。task_struct结构体中的pid实际上表示的是进程id or 线程id

  1. TGID(Thread Group Identifier)
    线程标识组,每一个线程的pid都不同,毕竟它们是本质上是轻量级进程,但是它们共享一个TGID,PID与TGID相同代表是主线程。
  2. TID(Thread Identifier)
    即线程标识符,用于唯一标识线程组内的每个线程。在 Linux 系统中,TID 和 PID 的概念在实现上是相同的,每个线程都有一个唯一的 TID,而对于单线程进程,其 TID 等于 PID

内核线程

内核线程是运行在内核态的轻量级进程,它与线程类似。 没有独立的地址空间和大部分用户空间资源。
主要用于管理和执行内核级的任务,比如硬件中断处理,系统调用,内存管理等。
内核线程主要有如下资源:

  1. 内核栈空间
  2. Task_struct
    内核线程的信息也同样存储在Task_struct中,不过它的mm以及active_mm 的值为NULL,代表没有虚拟内存空间。它们通常是共享内核的空间,而不需要虚拟内存空间。
    files_struct为NULL,没有文件描述符
    fs_struct为NULL,没有文件信息系统
    signal_struct和sigand_struct为NULL,没有信号处理表
  3. flags
    Task_struct中有一个flags字段,值为PF_KTHREAD 表示这是一个内核线程
    不同于普通进程
  4. 内核线程id
    内核线程tid,pid,tgid都是相同的,都是0。因为内核线程不参与普通任务,只操作内核。所以在设计上PGID为0,作为一个显著的标识。来确保内核线程在系统中的特殊性与隔离性

image

原创作者: lmy5215006 转载于: https://www.cnblogs.com/lmy5215006/p/18803757
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值