Linux 多进程、多线程、IO 模型(一)

本文最后更新于:2024年10月11日 晚上

进程

进程的概念

进程描述是一个程序执行过程,当程序执行后,执行过程开始,则进程产生, 执行过程结束, 则进程也就结束。

进程的特点

  • 进程是一个独立的可调度的活动, 由操作系统进行统一调度, 相应的任务会被调度到 CPU 中进行执行;
  • 进程一旦产生,则需要分配相关资源,同时进程是资源分配的最小单位;

进程和程序的区别

  • 程序是静态的,它是一些保存在磁盘上的指令的有序集合,没有任何执行的概念;
  • 进程是一个动态的概念,它是程序执行的过程,包括了动态创建、调度和消亡的整个过程

并行执行和并发执行

  • 并行执行: 表示多个任务能够同时执行,依赖于物理的支持,比如 cpu 是 4 核心,则可以同时执行 4 个任务
  • 并发执行: 在同一时间段有多个任务在同时执行,由操作系统调度算法来实现,比较典型的就是时间片轮转 network1

Linux 系统中管理进程的方式

  • 每个进程都需要与其他某一个进程建立父子关系, 对应的进程则叫做父进程;
  • Linux 系统会为每个进程分配 id , 这个 id 作为当前进程的唯一标识, 当进程结束, 则会回收;
  • 进程的 id 与 父进程的 id 分别通过 getpid() 与 getppid() 来获取
    • 例如以下代码就是获取当前进程 id 以及父进程 id:
      1
      2
      3
      4
      5
      6
      7
      8
      #include <stdio.h>
      #include <sys/types.h>
      #include <unistd.h>
      int main(void)
      {
      printf(" pid : %d ppid : %d\n",getpid(),getppid());
      return 0;
      }
      # 进程的地址空间 ## 进程的地址空间
  • 一旦进程建立之后, 系统则要为这个进程分配相应的资源, 一般 32 位系统会为每个进程分配 4G 的地址空间;
    • 4G 的地址空间结构如下:network1
      • 4G 的进程地址空间主要分为两部分:
        • (1)0 - 3G: 用户空间;
        • (2)3G - 4G: 内核空间;
      • 用户空间又具体分为如下区间:
        • stack: 存放非静态的局部变量
        • heap: 动态申请的内存
        • .bss: 未初始化过的全局变量(包括初始化为 0 的, 未初始化过的静态变量 (包括初始化为 0)
        • data: 初始化过并且值不为 0 的全局变量, 初始化过的不为 0 静态变量
        • .rodata: 只读变量(字符串之类)
        • .text: 程序文本段(包括函数,符号常量)
  • 当用户进程需要通过内核获取资源时, 会切换到内核态运行, 这时当前进程会使用内核空间的资源;
  • 用户需要切换到内核态运行时, 主要是通过 系统调用
  • 在程序执行过程中,操作系统会分配 4G 的虚拟地址空间
  • 虚拟地址空间中的每个地址都是一个虚拟地址
    • 虚拟地址:虚拟地址并不代表真实的内存空间, 而是一个用于寻址的编号
    • 物理地址:是指内存设备中真实存在的存储空间的编号
  • 虚拟地址通过映射的方式建立与物理地址的关联, 从而达到访问虚拟地址就可以访问到对应的物理地址
    • 在 cpu 中有一个硬件 MMU(内存管理单元), 负责虚拟地址与物理地址的映射管理以及虚拟地址访问
    • 操作系统可以设置 MMU 中的映射内存段network1
  • 在操作系统中使用虚拟地址空间主要是基于以下原因:
    • 直接访问物理地址, 会导致地址空间没有隔离, 很容易导致数据被修改
    • 通过虚拟地址空间可以实现每个进程地址空间都是独立的,操作系统会映射到不用的物理地址区间,在访问时互不干扰

进程的状态管理

  • 进程是动态过程,操作系统内核在管理整个动态过程时会使用了状态机
    • 给不同时间节点设计一个状态,通过状态来确定当前的过程进度
    • 在管理动态过程时,使用状态机是一种非常好的方式
  • 进程的状态一般分为如下:
    • 运行态 (TASK_RUNNING): 此时进程或者正在运行,或者准备运行, 就绪或者正在进行都属于运行态
    • 睡眠态 (): 此时进程在等待一个事件的发生或某种系统资源
      • 可中断的睡眠 (TASK_INTERRUPT): 可以被信号唤醒或者等待事件或者资源就绪
      • 不可中断的睡眠 (TASK_UNTERRUPT): 只能等待特定的事件或者资源就绪
    • 停止态 (TASK_STOPPED): 进程暂停接受某种处理。例如:gdb 调试断点信息处理。
    • 僵尸态(TASK_ZOMBIE):进程已经结束但是还没有释放进程资源network1

进程的相关命令

  • ps:
    • -A:列出所有的进程
    • -e: 与 -A 功能类似
    • -w: 显示加宽可以显示较多的资讯
    • -au: 显示较详细的信息
    • -aux: 显示所有包含其他使用者的进程 事例:ps -ef | grep “可执行文件名” 根据名称查找指定名字
  • top:实时显示相关进程

进程的创建

  • 为了提高计算机执行任务的效率,一般采用的解决方案就是能够让多个任务同时进行,这里可以使用并发与并行两种方式:
    • 并行: 在 cpu 多核的支持下,实现物理上的同时执行
    • 并发: 在有限的 cpu 核心的情况下(如只有一个 cpu 核心), 利用快速交替 (时间片轮转) 执行来达到宏观上的同时执行
  • 并行是基于硬件完成,而并发则可以使用软件算法来完成, 在完成任务时,可以创建多个进程并发执行
  • 创建进程的函数需要调用 fork() 函数, 则会产生一个新的进程
  • 调用 fork() 函数的进程叫做 父进程,产生的新进程则为子进程

fork()函数详解

  • 函数头文件:
    1
    2
    #include <sys/types.h>
    #include <unistd.h>
  • 函数原型
    1
    pid_t fork(void);
  • 函数功能:创建一个子进程
  • 函数返回值:
    • 成功: 返回给父进程是子进程的 pid , 返回给子进程的是 0
    • 失败: 返回 -1, 并设置 errno

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t cpid;
cpid = fork();
if (cpid == -1){
perror("fork(): ");
return -1;
}
printf("Hello world.\n");
return 0;
// 输出两个 Hello world
}
- 之所以 显示两个 “helloworld” 是因为打印语句在两个进程中都运行了,但是是父进程先执行还是子进程先执行取决于操作系统

示例 : 创建一个子进程,并打印 父进程与子进程的 pid:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static int idata = 200;
int main(void)
{
pid_t pid;
int istack = 300;
pid = fork();
if (pid < 0)
{
perror("fork():");
exit(-1);
}
else if (pid == 0)
{
idata *= 2;
istack *= 3;
}
printf(" %s %d %d\n",(pid == 0)?("child"):("parent"),idata,istack);
return 0;
}
- 通过 fork() 函数创建子进程之后,有如下特点: - 父子进程并发执行, 子进程从 fork() 之后开始执行network1 - 父子进程的执行顺序由操作系统算法决定的,不是由程序本身决定 - 子进程会拷⻉父进程地址空间的内容, 包括缓冲区、文件描述符等network1

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
pid_t cpid;
write(STDOUT_FILENO,"Hello",6);
fputs("Hello",stdout);
cpid = fork();
if (cpid == -1){
perror("fork(): ");
return -1;
}
return 0;
}
上面的代码最终会打印三次 Hello - 文件描述符拷⻉: - 每个进程都会维护一个文件表项,即文件描述符与文件指针的映射表 - 在 Linux 内核中有一个 struct file 结构体来管理所有打开的文件 - 当子进程拷⻉了父进程文件描述符后,则会共享文件状态标志与文件偏移量等信息network1

父子进程执行不同的任务

  • 使用 fork() 函数之后,会创建子进程,fork() 之后的代码会在父子进程中都执行一遍
    • 如果父子进程执行相同的任务,则正常执行
    • 如果父子进程执行不同的任务,则需要利用 fork() 函数返回值
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      #include <stdio.h>
      #include <stdlib.h>
      #include <sys/types.h>
      #include <unistd.h>
      int main(void)
      {
      pid_t cpid;
      cpid = fork();
      if (cpid == -1){
      perror("[ERROR] fork()");
      exit(EXIT_FAILURE);
      }else if(cpid == 0){
      printf("Child process task.\n");
      exit(EXIT_SUCCESS);
      }else if (cpid > 0){
      printf("Parent process task.\n");
      }
      printf("Child and Process Process task.\n");
      return 0;
      }
  • 在创建多个进程时, 最主要的原则为 由父进程统一创建,统一管理, 不能进行递归创建
    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
    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <sys/wait.h>
    int main(void)
    {
    int cpid;
    cpid = fork();
    if (cpid == -1){
    perror("fork(): ");
    exit(EXIT_FAILURE);
    }else if (cpid == 0){
    printf("The child process < %d > running...\n",getpid());
    sleep(2);
    printf("The child process < %d > has exited\n",getpid());
    exit(EXIT_SUCCESS);
    }else if (cpid > 0){
    cpid = fork();
    if (cpid == -1){
    perror("fork(): ");
    }else if (cpid == 0){
    printf("The child process < %d > running...\n",getpid());
    sleep(3);
    printf("The child process < %d > has exited\n",getpid());
    exit(EXIT_SUCCESS);
    }else if (cpid > 0){

    }
    }
    return 0;
    }
    # 进程的退出
  • 在进程结束时,需要释放进程地址空间 以及内核中产生的各种数据结构
  • 源的释放需要通过调用 exit 函数或者 _exit 函数来完成
  • 在程序结束时,会自动调用 exit 函数
  • exit 函数让当前进程退出, 并刷新缓冲区
  • exit 函数信息如下:
    • 函数头文件:
      1
      #include <stdlib.h>
    • 函数原型
      1
      void exit(int status);
    • 函数功能: 结束进程,并刷新缓冲区
    • 函数参数:
      • status: 退出状态值
        • 在系统中定义了两个状态值 : EXIT_SUCCESS: 正常退出 EXIT_FAILURE: 异常退出, 具体定义在 stdlib.h 中

示例: 创建一个子进程,让子进程延时 3s 后退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main(void)
{
pid_t cpid;
cpid = fork();
if (cpid == -1){
perror("[ERROR] fork(): ");
exit(EXIT_FAILURE);
}else if(cpid == 0){
printf("Child Process < %d > running...\n",getpid());
sleep(3);
printf("Child Process < %d > has exited.\n",getpid());
exit(EXIT_SUCCESS);
}else if(cpid > 0){
sleep(5);
}
return 0;
}

  • _exit 函数头文件
    1
    #include <unistd.h>
  • 函数原型
    1
    void _exit(int status);
  • 函数参数: status: 进程退出的状态值
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    int main(void)
    {
    pid_t cpid;
    cpid = fork();
    if (cpid == -1){
    perror("[ERROR] fork(): ");
    exit(EXIT_FAILURE);
    }else if(cpid == 0){
    printf("Child Process < %d > running...\n",getpid());
    sleep(3);
    printf("Child Process < %d > has exited.\n",getpid());
    _exit(EXIT_SUCCESS);
    }else if(cpid > 0){
    sleep(5);
    }
    return 0;
    }
  • exit 函数与 _exit 函数功能相似, 但有很多不同, 具体如下:
    • _exit() 属于系统调用, 能够使进程停止运行, 并释放空间以及销毁内核中的各种数据结构
    • exit() 基于_exit() 函数实现, 属于库函数, 可以清理 I/O 缓冲区

示例:验证 exit 函数刷新缓冲区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t cpid;
cpid = fork();
if (cpid == -1){
perror("[ERROR] fork(): ");
exit(EXIT_FAILURE);
}else if(cpid == 0){
printf("I/O BUFFER.");
sleep(3);
exit(EXIT_SUCCESS);
}else if(cpid > 0){
sleep(5);
}
return 0;
}


Linux 多进程、多线程、IO 模型(一)
https://zzmes.github.io/2024/10/11/c-thread/
作者
YangYangYang
发布于
2024年10月11日
许可协议