6 进程

6.1 背景

6.1.1 操作系统

  • 批处理系统:当一个任务执行完成以后,批处理系统会自动地取出下一个任务。缺点:任务执行的过程,会经常需要等待IO操作,这会导致CPU经常性地空闲。
  • 分时系统:操作系统会给每个任务分配一些时间来使用CPU,这样CPU就不会沉浸在一个大型的任务中,期间的用户操作也会很快地得到响应。

6.1.2 进程的定义

  • 从用户的角度来看,进程是一个程序的动态执行过程。

    • 程序是静态文件,是一系列二进制指令和数据的集合,程序通常存储在磁盘当中;
    • 进程则是动态的,当程序被触发以后(比如用户启动程序或者被其他进程启动程序),启动者的权限和属性以及程序的指令和数据会被加载到内存当中,并且占用CPU和其他系统资源动态地执行指令和读写数据。进程的状态会在动态地在创建、调度、运行和消亡之间转换。
  • 从操作系统的角度来看,进程是资源分配的基本单位。

    • 进程需要占用CPU资源以执行程序指令;
    • 进程需要占据存储资源来保存状态,包括数据段、代码段、堆以及其他内存空间;
    • 进程需要占用资源管理打开的文件、挂起的信号、内核内部数据、处理器状态、存在内存映射的内存空间以及执行线程,而执行线程的信息则包含程序计数器、栈和寄存器状态。

综上,进程是处于执行期的程序和相关资源的总称。即便通过同一份静态程序,也可以启动多个不同的进程。

6.1.3 CPU虚拟化

共享资源的方式:时分共享(CPU),空分共享(内存)。

通过让一个进程运行一段时间,然后切换到其他进程。

代价:性能损失。

时分共享的实现:

  • 底层机制:上下文切换
  • 上层策略:调度策略

占用的资源:

  • CPU的状态:寄存器(PC(下一条指令的地址)、SP(栈顶)、BP(base point))
  • 占用的内存:进程的虚拟空间地址
  • 持久性的存储设备:打开文件列表

实现上下文切换的方法:

  1. 直接运行协议(无限制)
    直接运行协议
    当操作系统希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码(从磁盘)加载到内存中,找到入口点(main()函数)跳转到那里,并开始运行用户的代码。

    优点:简单,高效。

    缺点:

    • 没有控制权:不能让一个进程停下来并切换到另一个进程,从而实现CPU虚拟化所需的时分共享;
    • 不安全:没有限制应用程序的权限,即不能控制访问得发的内存空间和执行特权指令。
  2. 受限直接运行协议

受限直接运行协议

(1) 受限制的操作

新的处理器模式:CPU的模态。

  • 用户态:在用户模式下运行的代码会受到限制。例如,在用户模式下运行时,进程不能发出I/O请求。这样做会导致处理器引发异常,操作系统可能会终止进程;
  • 内核态:操作系统(或内核)以内核模式运行。在此模式下,运行的代码可以做它喜欢的事,包括特权操作,如发出I/O请求和执行所有类型的受限指令。

要执行系统调用,程序必须执行特殊的陷阱(trap)指令。该指令同时跳入内核并将特权级别提升到内核模式。一旦进入内核,系统就可以执行任何需要的特权操作(如果允许)从而为调用进程执行所需的工作。完成后,操作系统调用一个特殊的从陷阱返回(returnfrom-trap)指令,该指令返回到发起调用的用户程序中,同时将特权级别降低,回到用户模式。

内核通过在启动时设置陷阱表(trap table)来实现如何知道在操作系统内运行哪些代码。

(2)进程之间的切换

  • 协作方式:等待系统调用。

    操作系统相信系统的进程会合理运行。运行时间过长的进程被假定会定期放弃CPU,以便操作系统可以决定运行其他任务在协作调度系统中,操作系统通过等待系统调用,或某种非法操作发生,从而重新获得 CPU 的控制权;

  • 非协作方式:时钟中断。

  1. 受限直接运行协议(时钟中断)

受限直接运行协议

时钟设备可以编程为每隔几毫秒产生一次中断。产生中断时,当前正在运行的进程停止操作系统中预先配置的中断处理程序(interrupt handler)会运行。此时,操作系统重新获得CPU的控制权,因此可以做它想做的事:停止当前进程,并启动另一个进程。

当操作系统决定切换进程时,需要首先进行上下文切换(contextswitch),就是为当前正在执行的进程保存一些寄存器的值(例如,到它的内核栈),并为即将执行的进程恢复一些寄存器的值(从它的内核栈)。这样一来,操作系统就可以确保最后执行从陷阱返回指令时不是返回到之前运行的进程,而是继续执行另一个进程。

6.2 进程相关概念

6.2.1 内核管理进程信息

内核把所有的进程维护成一个名为任务队列的双向循环链表。链表的节点类型名为task_struct,它声明在/include/linux/sched.h中, task_struct可以被称为进程控制块。它里面存储了进程的各种静态信息,包括打开的文件、进程的地址空间、挂起的信号、进程的状态等等。

//某个版本的完整task_struct
struct task_struct
{
    //说明了该进程是否可以执行,还是可中断等信息
    volatile long state;
    //Flage 是进程号,在调用fork()时给出
    unsigned long flags;
    //进程上是否有待处理的信号
    int sigpending;
    //进程地址空间,区分内核进程与普通进程在内存存放的位置不同
    mm_segment_t addr_limit; //0-0xBFFFFFFF for user-thead
    //0-0xFFFFFFFF for kernel-thread
    //调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度
    volatile long need_resched;
    //锁深度
    int lock_depth;
    //进程的基本时间片
    long nice;
    //进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR, 分时进程:SCHED_OTHER
    unsigned long policy;
    //进程内存管理信息
    struct mm_struct *mm;
    int processor;
    //若进程不在任何CPU上运行, cpus_runnable 的值是0,否则是1 这个值在运行队列被锁时更新
    unsigned long cpus_runnable, cpus_allowed;
    //指向运行队列的指针
    struct list_head run_list;
    //进程的睡眠时间
    unsigned long sleep_time;
    //用于将系统中所有的进程连成一个双向循环链表, 其根是init_task
    struct task_struct *next_task, *prev_task;
    struct mm_struct *active_mm;
    struct list_head local_pages; //指向本地页面
    unsigned int allocation_order, nr_local_pages;
    struct linux_binfmt *binfmt; //进程所运行的可执行文件的格式
    int exit_code, exit_signal;
    int pdeath_signal; //父进程终止是向子进程发送的信号
    unsigned long personality;
    //Linux可以运行由其他UNIX操作系统生成的符合iBCS2标准的程序
    int did_exec:1;
    pid_t pid; //进程标识符,用来代表一个进程
    pid_t pgrp; //进程组标识,表示进程所属的进程组
    pid_t tty_old_pgrp; //进程控制终端所在的组标识
    pid_t session; //进程的会话标识
    pid_t tgid;
    int leader; //表示进程是否为会话主管
    struct task_struct *p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_osptr;
    struct list_head thread_group; //线程链表
    struct task_struct *pidhash_next; //用于将进程链入HASH表
    struct task_struct **pidhash_pprev;
    wait_queue_head_t wait_chldexit; //供wait4()使用
    struct completion *vfork_done; //供vfork() 使用
    unsigned long rt_priority; //实时优先级,用它计算实时进程调度时的weight值it_real_value
    //it_real_incr用于REAL定时器,单位为jiffies, 系统根据it_real_value设置定时器的第一个终止时间. 在定时器到期时,向进程发送SIGALRM信号,同时根据it_real_incr重置终止时间,it_prof_value,it_prof_incr用于Profile定时器,单位为jiffies。当进程运行时,不管在何种状态下,每个tick都使it_prof_value值减一,当减到0时,向进程发送信号SIGPROF,并根据it_prof_incr重置时间
    //it_virt_value,it_virt_value用于Virtual定时器,单位为jiffies。当进程运行时,不管在何种状态下,每个tick都使it_virt_value值减一,当减到0时,向进程发送信号SIGVTALRM,根据it_virt_incr重置初值
    unsigned long it_real_value, it_prof_value, it_virt_value;
    unsigned long it_real_incr, it_prof_incr, it_virt_value;
    struct timer_list real_timer; //指向实时定时器的指针
    struct tms times; //记录进程消耗的时间
    unsigned long start_time; //进程创建的时间
    //记录进程在每个CPU上所消耗的用户态时间和核心态时间
    long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS];
    //内存缺页和交换信息:
    //min_flt, maj_flt累计进程的次缺页数(Copy on Write页和匿名页)和主缺页数(从映射文件或交换设备读入的页面数);nswap记录进程累计换出的页面数,即写到交换设备上的页面数。cmin_flt, cmaj_flt, cnswap记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数。在父进程回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中
    unsigned long min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;
    int swappable:1; //表示进程的虚拟地址空间是否允许换出进程认证信息
    //uid,gid为运行该进程的用户的用户标识符和组标识符,通常是进程创建者的uid,gid
    //euid,egid为有效uid,gid
    //fsuid,fsgid为文件系统uid,gid,这两个ID号通常与有效uid,gid相等,在检查对于文件系统的访问权限时使用他们。
    //suid,sgid为备份uid,gid
    uid_t uid,euid,suid,fsuid;
    gid_t gid,egid,sgid,fsgid;
    int ngroups; //记录进程在多少个用户组中
    gid_t groups[NGROUPS]; //记录进程所在的组
    //进程的权能,分别是有效位集合,继承位集合,允许位集合
    kernel_cap_t cap_effective, cap_inheritable, cap_permitted;
    int keep_capabilities:1;
    struct user_struct *user;
    struct rlimit rlim[RLIM_NLIMITS]; //与进程相关的资源限制信息
    unsigned short used_math; //是否使用FPU
    char comm[16]; //进程正在运行的可执行文件名
    //文件系统信息
    int link_count, total_link_count;
    //NULL if no tty 进程所在的控制终端,如果不需要控制终端,则该指针为空
    struct tty_struct *tty;
    unsigned int locks;
    //进程间通信信息
    struct sem_undo *semundo; //进程在信号灯上的所有undo操作
    struct sem_queue *semsleeping; //当进程因为信号灯操作而挂起时,他在该队列中记录等待的操作进程的CPU状态,切换时,要保存到停止进程的task_struct中
    struct thread_struct thread;
    //文件系统信息
    struct fs_struct *fs;
    //打开文件信息
    struct files_struct *files;
    //信号处理函数
    spinlock_t sigmask_lock;
    struct signal_struct *sig; //信号处理函数
    sigset_t blocked; //进程当前要阻塞的信号,每个信号对应一位
    struct sigpending pending; //进程上是否有待处理的信号
    unsigned long sas_ss_sp;
    size_t sas_ss_size;
    int (*notifier)(void *priv);
    void *notifier_data;
    sigset_t *notifier_mask;
    u32 parent_exec_id;
    u32 self_exec_id;
    spinlock_t alloc_lock;
    void *journal_info;
};

6.2.2 进程的状态

在进程从创建到消亡的过程中,进程会存在很多种状态。其中最基本的三种的状态:

  • 执行态:该进程正在运行,即进程正在占用CPU。
  • 就绪态:进程已经具备执行的一切条件,正在等待分配CPU的处理时间片。
  • 阻塞态:进程不能使用CPU,通常由于等待IO操作、信号量或者其他操作。

其细致划分如下:

/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
    "R (running)", /* 0 */
    "S (sleeping)", /* 1 */
    "D (disk sleep)", /* 2 */
    "T (stopped)", /* 4 */
    "t (tracing stop)", /* 8 */
    "X (dead)", /* 16 */
    "Z (zombie)", /* 32 */
};
进程状态 标识 状态说明
运行态(Running) R 进程正在CPU上运行,或处于就绪状态等待CPU调度。
睡眠态(Sleeping) S/D - S:可中断睡眠,进程等待资源(如 IO),收到信号可被唤醒。
- D:不可中断睡眠,进程正在等待关键资源(如磁盘 IO),不能被信号唤醒,强行终止可能导致数据丢失。
停止态(Stopped) T/t - T:进程被暂停(如收到SIGSTOP信号),可通过SIGCONT信号恢复运行。 - t:进程处于追踪状态,通常是被调试器(如 gdb)跟踪。
僵尸态(Zombie) Z 进程已终止,但父进程未读取其退出状态,进程表中仍保留该进程的条目。
死亡态(Dead) X 进程已完全终止,资源被释放,ps指令中通常不显示该状态。

6.2.3 进程标识符

为了方便定位每个进程,操作系统为每个进程分配了一个唯一的正整数标识符,称为进程ID(PID)。在Linux中,进程之间存在着亲缘关系,如果一个进程在执行过程中启动了另外一个进程,那么启动者就是父进程,被启动者就是子进程。从task_struct声明可知,进程的信息中包含它的进程ID和父进程ID,实际上PID和PCB之间存在着一一对应的关系。

在Linux启动时,如果所有的硬件已经配置好的情况下,进程0会被bootloader程序启动起来,它会配置实时时钟,启动init进程(进程1)和页面守护进程(进程2)。init就是所谓的“盘古”进程(在新版本中被systemd取代),它会启动shell进程。在多用户的情况下,init会开启运行/etc/rc中配置的脚本进程,然后这个进程再从/etc/ttys中读取数据。/etc/ttys中列出了所有的终端,终端可以用于让用户从某种渠道登陆操作系统。

6.2.4 前台进程和后台进程

在Linux/Unix系统中,进程按与终端的交互方式可分为前台进程和后台进程,两者的核心区别在于是否占据终端的输入输出控制权。

  1. 前台进程
    • 定义:直接与当前终端关联,占据终端的输入输出(STDIN/STDOUT/STDERR),用户可以通过终端直接与其交互(如输入命令、接收输出)。
    • 特点:
      • 运行时会阻塞终端,此时无法在同一终端执行其他命令(除非进程结束或被暂停)。
      • 可通过Ctrl+C发送SIGINT信号终止,通过Ctrl+Z发送SIGTSTP信号暂停(转入后台并停止运行)。
  2. 后台进程
    • 定义:不占据终端的输入输出控制权,在后台独立运行,终端可同时接收其他命令。
    • 特点:
      • 启动后立即返回终端提示符,用户可继续输入其他命令。
      • 默认情况下,后台进程的标准输出STDOUT和标准错误STDERR仍会显示在终端(可通过重定向避免)。
      • 无法直接通过终端输入与后台进程交互(如需交互需先转入前台)。
      • 关闭终端时,后台进程可能会被终止(除非使用nohup或setsid等工具脱离终端)。

6.2.5 总结

  1. 进程之间是隔离的(感知不到内核和其他进程的存在);
  2. 进程是资源分配的最小单位。

6.3 进程相关的命令

6.3.1 ps

ps(process status)命令可以查看进程状态:

6.3.1.1 无选项
$ ps
    PID TTY          TIME CMD
   1712 pts/0    00:00:00 bash
  29367 pts/0    00:00:00 ps
  • PID:进程唯一标识符
  • TTY:进程关联的终端(? 表示不依赖终端)
  • TIME:进程累计占用的CPU时间
  • CMD:启动进程的命令
6.3.1.2 ps -elf
ps -elf
F S UID          PID    PPID  C PRI  NI ADDR SZ WCHAN  STIME TTY          TIME CMD
4 S root           1       0  0  80   0 - 42376 -      10月18 ?      00:00:18 /sbin/init splash
1 S root           2       0  0  80   0 -     0 -      10月18 ?      00:00:00 [kthreadd]
1 I root           3       2  0  60 -20 -     0 -      10月18 ?      00:00:00 [rcu_gp]
...

选项:

  • -e:显示系统中所有进程(等同于-A),包括所有用户的进程,不受终端限制。
  • -l:以长格式输出,显示更详细的进程信息(如优先级、状态、父进程ID等)。
  • -f:显示完整格式信息,包括UID、PPID(父进程 ID)、CMD(完整命令)等。

字段含义:

字段 含义说明
F 进程标志(Flag),表示进程的权限和状态(如是否为超级用户进程、是否被锁定等)
S 进程状态(State),与ps aux中的STAT类似(R运行、S睡眠、Z僵尸等)
UID 进程所属用户的ID
PID 进程唯一标识符(Process ID)
PPID 父进程ID(Parent Process ID),显示进程的父级关系
C 进程的CPU使用率(百分比,较简略)
PRI 进程优先级(Priority),数值越小优先级越高(内核动态调整)
NI 进程nice值(调整优先级的用户空间值),范围-20(最高)到19(最低)
ADDR 进程在内存中的地址(多数情况下显示-,表示无需关注)
SZ 进程使用的虚拟内存大小(以页面为单位,1 页≈4KB)
WCHAN 进程等待的内核函数(若进程处于睡眠状态,显示等待的原因;运行中则为-
STIM 进程启动时间(格式为时:分或月-日,取决于进程启动时间)
TTY 进程关联的终端(?表示无终端,通常为后台进程)
TIME 进程累计占用的CPU时间(时:分:秒)
CMD 启动进程的完整命令(包括路径和参数)
6.3.1.3 ps aux
$ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.3 169504 12912 ?        Ss   10月18   0:18 /sbin/init splash
root           2  0.0  0.0      0     0 ?        S    10月18   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        I<   10月18   0:00 [rcu_gp]
...

选项:

  • a:显示所有用户的进程(包括当前终端外的其他用户进程),但默认不包含无终端(tty)的进程。
  • u:以用户为中心的格式输出,显示更详细的信息(如进程所属用户、CPU/内存使用率等),而非默认的简洁格式。
  • x:显示无终端关联的进程(如后台服务进程,这类进程的TTY字段通常为?)。

字段含义:

字段 含义说明
USER 进程所属的用户名
PID 进程的唯一标识符(Process ID)
%CPU 进程当前的CPU使用率(百分比)
%MEM 进程当前的内存使用率(百分比,基于物理内存)
VSZ 进程使用的虚拟内存大小(单位:KB),包括所有代码、数据、共享库和未使用的交换空间等
RSS 进程实际使用的物理内存大小(单位:KB),不包括未使用的交换空间
TTY 进程关联的终端设备(?表示无终端,通常为后台服务进程)
STAT 进程状态码,常见状态:R(运行中或就绪)、S(睡眠,可被唤醒)、D(深度睡眠,不可中断)、Z(僵尸进程)、T(暂停)、<(高优先级)、N(低优先级)等
START 进程启动的时间或日期(启动时间较近显示 “时:分”,较早则显示 “月-日”)
TIME 进程累计占用的CPU时间(格式:时:分:秒)
COMMAN 启动进程的完整命令(包括路径和参数,若命令过长可能被截断)

6.3.2 top

top是Linux/Unix系统中用于动态监控进程状态的交互式命令,能够实时显示系统的资源使用情况(如 CPU、内存、进程活动等),并按资源占用排序,常用于排查性能问题。

$ top
top - 20:06:28 up 14 days,  1:53,  2 users,  load average: 0.00, 0.00, 0.00
任务: 296 total,   1 running, 295 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :   3876.0 total,   1084.8 free,   1004.6 used,   1786.5 buff/cache
MiB Swap:   2048.0 total,   2048.0 free,      0.0 used.   2609.8 avail Mem 

 进程号 USER      PR  NI    VIRT    RES    SHR    %CPU  %MEM     TIME+ COMMAND                                                  
     46 root      20   0       0      0      0 S   0.3   0.0   0:46.69 kcompactd0                                               
  29386 wowyummy  20   0   15124   4004   3220 R   0.3   0.1   0:00.44 top                                                      
      1 root      20   0  169504  12912   8304 S   0.0   0.3   0:18.57 systemd                                                  
      2 root      20   0       0      0      0 S   0.0   0.0   0:00.37 kthreadd                                                 
      3 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 rcu_gp
      
      ...
  1. 系统状态概览(顶部5行)

    • 第一行:系统时间、运行时间、登录用户数、系统负载(1分钟、5分钟、15分钟的平均负载,数值越小越空闲,超过CPU核心数可能表示过载)。
    • 第二行:进程总数统计(总进程数、运行中running、睡眠中sleeping、停止stopped、僵尸zombie)。
    • 第三行:CPU使用率(按内核视角统计,总和可能超过100%多核心情况)。字段含义:
      • us:用户空间进程占用CPU百分比(如应用程序)。
      • sy:内核空间进程占用CPU百分比(如系统调用)。
      • ni:调整过优先级(nice值)的用户进程占用CPU百分比。
      • id:CPU空闲百分比。
      • wa:CPU等待I/O(如磁盘、网络)的时间百分比(过高可能表示I/O瓶颈)。
      • hi/si:硬件/软件中断占用CPU百分比。
      • st:被虚拟机偷走的CPU时间(仅虚拟机中有效)。
    • 第四行:物理内存(RAM)使用情况。
      • total:总物理内存。
      • used:已使用内存。
      • free:完全空闲内存。
      • buffers:用于磁盘缓存的内存(可释放)。
    • 第五行:交换分区(Swap)使用情况(类似物理内存,用于内存不足时临时存储数据)。
  2. 进程列表

    字段 含义说明
    PID 进程 ID。
    USER 进程所属用户。
    PR 进程优先级(数值越小优先级越高,RT表示实时进程)。
    NI 进程nice值(调整优先级,范围-2019,默认0)。
    VIRT 进程使用的虚拟内存总量(包括物理内存、交换分区、文件等,单位:KB)。
    RES 进程使用的物理内存(不包括交换分区,单位:KB,RES=CODE+DATA)。
    SHR 进程共享的内存(包括共享库,单位:KB,可被多个进程共享)。
    S 进程状态(同ps命令:R运行、S睡眠、Z僵尸等)。
    %CPU 进程自上次刷新以来的CPU使用率(百分比)。
    %MEM 进程使用的物理内存占总内存的百分比。
    TIME+ 进程累计占用的CPU时间(精确到0.01秒)。
    COMMAND 进程启动命令(可能被截断)。

6.3.3 kill

kill命令可以用来给指定的进程发送信号。当进程处于后台的时候,只能通过kill命令发送信号给它。

$kill -9 pid 	# 以异常方式终止进程
$kill -15 pid 	# 以正常方式终止进程
$kill -1 pid	# 重新启动进程
$kill -2 pid	# 中断进程
$kill -19 pid	# 暂停进程
$kill -l		# 显示所有信号

使用shell启动进程的时候如果在末尾加上&符号可以用来直接运行后台进程:

$vim test &
#它会输出一个整形数字,表示它的任务编号

使用ctrl+z可以暂停当前运行的前台进程,并将其放入后台。它也会输出一个任务编号到屏幕上。
使用jobs命令可以查看和管理所有的后台任务,使用fg命令可以将后台进程拿到前台来。使用bg命令可以将后台暂停的程序运行起来。

6.3.4 pstree

pstree命令用于以树状结构显示系统中进程之间的父子关系,直观展示进程的层级依赖。

$ pstree
systemd─┬─ModemManager───2*[{ModemManager}]
        ├─NetworkManager───2*[{NetworkManager}]
        ├─VGAuthService
        ├─accounts-daemon───2*[{accounts-daemon}]
        ├─acpid
        ├─avahi-daemon───avahi-daemon
        ├─bluetoothd
        ├─colord───2*[{colord}]
...

6.4 进程相关的基础系统调用

6.4.1 获取进程的标识

使用系统调用getpidgetppid可以获取当前运行进程的进程ID和父进程ID。

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);
返回值
    成功:getpid:该进程的PID;
    	 getppid :父进程的PID。
    失败:只要返回,就不会失败。

示例:

#include <func.h>

int main(int argc, char* argv[])
{
    printf("pid = %d\n", getpid());
    printf("ppid = %d\n", getppid());
    
    sleep(10);
    return 0;
}

6.4.2 创建进程

6.4.2.1 fork函数

fork用于拷贝当前进程以创建一个新进程。

#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);
返回值
    成功:父进程:子进程的PID;
    	 子进程:0。
    失败:不会创建子进程,父进程返回-1,并设置error。

注意:

  • 操作系统不保证进程的执行先后顺序,也就是说,到底是父进程先执行还是子进程先执行是不确定的。

  • 父子进程都是从fork返回,子进程不会执行之前的代码。

  • 父子进程之间的PPID也不一样,其中子进程的PPID的进程为它的父进程。

示例:

#include <func.h>

int main(int argc, char* argv[])
{
    printf("BEGIN\n");

    pid_t pid = fork();

    switch (pid) {
    case -1:
        error(1, errno, "fork");
    case 0:
        // 子进程
        printf("I am a child.\n");
        printf("child: pid = %d, ppid = %d\n", getpid(), getppid());
        break;
    default:
        // 父进程
        printf("I am a father.\n");
        printf("parent: pid = %d, childpid = %d\n", getpid(), pid);
        break;
    }

    printf("BYE BYE\n");
    return 0;
}


// 运行结果
// BEGIN
// I am a father.
// parent: pid = 6444, childpid = 6445.
// ByeBye
// I am a child.
// child: pid = 6445, ppid = 1. //父进程先于子进程退出,子进程变成了孤儿进程,父进程变为init(1)
// ByeBye
6.4.2.2 fork函数的实现原理
  1. 复制父进程proc结构体,继承父进程的属性,并修改相应的信息(子进程的task_struct)。
  2. 复制父进程的页表。
6.4.2.3 写时复制(COW)

当执行了fork了以后,父子进程地址空间的内容是完全一致,所以完全可以共享同一片物理内存,也就是父子进程的同一个虚拟地址会对应同一个物理内存字节。通常来说,内存的分配单位是页,我们可以为每一个内存页维持一个引用计数。代码段的部分因为只读,所以完全可以多个进程同时共享。而对于地址空间的其他部分,当进程对某个内存页进行写入操作的时候,我们再真正执行被修改的虚拟内存页分配物理内存并拷贝数据,这就是所谓的写时复制。在执行拷贝以后,同样的虚拟地址就无法对应同样物理内存字节了。

6.4.2.4 父子进程资源共享
  1. 代码段:父子进程共享。因为代码段是无法修改的。

  2. 栈、堆、数据段:父子进程私有。

    #include <func.h>
    
    int g_value = 10;  // 数据段
    int main(int argc, char* argv[])
    {
        int l_value = 20;   // 栈
        int* d_value = (int*)malloc(sizeof(int)); // 堆
        *d_value = 30;
    
        pid_t pid = fork();
        switch (pid) {
        case -1:
            error(1, errno, "fork");
        case 0:
            // 子进程
            g_value += 100;
            l_value += 100;
            *d_value += 100;
            printf("child: g_value = %d, l_value = %d, d_value = %d\n", g_value, l_value, *d_value);
            exit(0);
        default:
            // 父进程
            sleep(2);
            printf("parent: g_value = %d, l_value = %d, d_value = %d\n", g_value, l_value, *d_value);
            exit(0);
        }
        return 0;
    }
    
    // 运行结果:
    // child: g_value = 110, l_value = 120, d_value = 130
    // parent: g_value = 10, l_value = 20, d_value = 30
    
  3. 用户态缓冲区(文件流):父子进程私有。

    #include <func.h>
    
    int main(int argc, char* argv[])
    {
        printf("BEGIN:"); // stdout是行缓冲
    
        pid_t pid = fork();
        switch (pid) {
        case -1:
            error(1, errno, "fork");
        case 0:
            // 子进程
            printf("I am a baby\n");
            exit(0);
        default:
            // 父进程
            sleep(2);
            printf("Who's your daddy?\n");
            exit(0);
        }
        return 0;
    }
    
    // 运行结果:
    // BEGIN:I am a baby
    // BEGIN:Who's your daddy?
    
  4. 文件描述符列表:父子进程私有。

    #include <func.h>
    
    int main(int argc, char* argv[])
    {
        int fd = open("text", O_RDWR | O_CREAT | O_TRUNC, 0666);
        printf("pos: %ld\n", lseek(fd, 0, SEEK_CUR));
    
        pid_t pid = fork();
        int newfd;
        switch (pid) {
        case -1:
            error(1, errno, "fork");
        case 0: 
            // 子进程
            write(fd, "Hello world", 11);
            
            close(STDOUT_FILENO);
            close(STDERR_FILENO);
            newfd = dup(fd);    // newfd=1
            printf("newfd = %d\n", newfd);
            exit(0);
        default:
            // 父进程
            sleep(2);
            printf("pos: %ld\n", lseek(fd, 0, SEEK_CUR));
            
            newfd = dup(fd);
            printf("newfd = %d\n", newfd);  // newfd = 4
            exit(0);
        }
        return 0;
    }
    
    // 运行结果:
    // pos: 0
    // pos: 21
    // newfd = 4
    
  5. 打开文件列表:父子进程共享。

    父子进程共享偏移量在文件是标准输出的时候非常有效,这样可以在一个终端界面上显示父子进程的输出。如果尝试对一个文件使用多进程读写,由于共享偏移量的原因,速度并不会更快。此外,文件读写的瓶颈是磁盘的读写效率,所以即便父子进程设法不共享偏移量,对文件读写的速度提升也不会有效果。

6.4.3 终止进程

在进程阶段,进程总共有5种终止方式,其中3种是正常终止,2种是异常终止:

终止方式 终止情况
在main函数调用return 正常
调用exit函数(库函数) 正常
调用_Exit函数或者_exit函数(系统调用) 正常
调用abort函数 异常
接受到能引起进程终止的信号 异常
6.4.3.1 正常终止

进程正常终止

  1. exit函数

    #include <stdlib.h>
    
    void exit(int status);
    参数
        status:传出参数,退出状态码,返回给父进程。
        	status=0:表示进程正常退出(执行成功)。
    		status≠0:表示进程异常退出(执行失败),非零值可自定义,用不同数值表示不同错误类型。
    

    执行流程:

    • 执行atexit()注册的函数:

      #include <stdlib.h>
      
      int atexit(void (*function)(void));
      参数
          void (*function)(void):函数指针,参数与返回值可通过类型转换得到。注册一个在进程正常终止时                            自动执行的函数,即退出处理函数。
      返回值
          成功:0。
          失败:非零值。
      
    • 刷新用户态缓冲区;

    • 调用_exit()函数,正常终止进程。

    示例:

    #include <func.h>
    
    // 执行一些资源清理操作
    void func(void) {
        printf("I am going to die...");
    }
    
    int main(int argc, char* argv[])
    {
        // 调用atexit()注册函数,不会执行func
        int err = atexit(func);
        if (err) {
            error(1, 0, "atexit");
        }
    
        // ...
        printf("Hello world!");
    
        exit(123);// 执行func
    }
    
    // 运行结果:
    // Hello world!I am going to die...
    
  2. _exit()函数/_Exit()函数

    #include <unistd.h>
    
    void _exit(int status);// _exit是传统Unix系统的接口(属于POSIX标准)
    
    #include <stdlib.h>
    
    void _Exit(int status);// _Exit是C标准库(C99及以后)的接口
    参数
        传出参数,退出状态码,返回给父进程。
    

    注意:如果在程序没有刷新缓冲区,并使用了_exit_Exit函数,很容易出现缓冲区内容丢失的情况。

    示例:

    #include <func.h>
    
    // 执行一些资源清理操作
    void func(void) {
        printf("I am going to die...");
    }
    
    int main(int argc, char* argv[])
    {
        // 调用atexit()注册函数
        int err = atexit(func);
        if (err) {
            error(1, 0, "atexit");
        }
    
        // ...
        printf("Hello world");
    
        _exit(123);
    }
    
    // 运行结果:
    // 无任何输出。
    
6.4.3.2 异常终止

使用abort可以主动调用异常终止。

#include <stdlib.h>

void abort(void);

原理:调用abort后,内核给进程发送SIGABRT信号,信号导致进程异常终止。

示例:

#include <func.h>

// 执行一些资源清理操作
void func(void) {
    printf("I am going to die...");
}

int main(int argc, char* argv[])
{
    // 调用atexit()注册函数
    int err = atexit(func);
    if (err) {
        error(1, 0, "atexit");
    }

    // ...
    printf("Hello world");

    abort();

    printf("You cannot see me!\n");
}

// 运行结果:
// Aborted(core dumped)

6.4.4 进程控制

6.4.4.1 孤儿进程

如果父进程先于子进程退出,则子进程成为孤儿进程,此时将自动被PID为1的进程(即init)收养。当一个孤儿进程退出以后,它的资源清理会交给它的父进程(此时为init)来处理。但在init进程清理子进程之前,它一直消耗系统的资源,所以要尽量避免。

示例:

#include <func.h>

int main(int argc, char* argv[])
{dd
    pid_t pid = fork();
    switch(pid) {
    case -1:
        error(1, errno, "fork");
    case 0:
        // 子进程
        sleep(2);
        printf("pid = %d, ppid = %d\n", getpid(), getppid());
        exit(0);
    default:
        // 父进程
        printf("Parent: pid = %d, childPid = %d\n", getpid(), pid);
        exit(0);
    }
    return 0;
}

// 运行结果:
// Parent: pid = 12423, childPid = 12424
// pid = 12424, ppid = 1
6.4.4.2 僵尸进程

如果子进程先退出,系统不会自动清理掉子进程的环境,而必须由父进程调用waitwaitpid函数来完成清理工作,如果父进程不做清理工作,则已经退出的子进程将成为僵尸进程(defunct),在系统中如果存在的僵尸(zombie)进程过多,将会影响系统的性能,所以必须对僵尸进程进行处理。

原理:当一个子进程执行结束时,有一些信息会保存在内核,方便父进程查看,同时它会向它的父进程发送一个SIGCHLD信号,但是父进程会默认忽略信号,因此需要使用waitwaitpid函数给僵尸进程收尸。

6.4.4.3 waitwaitpid
  1. wait函数会无限期阻塞,直到有一个子进程终止。

    #include <sys/wait.h>
    pid_t wait(int *wstatus);
    
    参数
        wstatus:一个指向整数的指针,用于存储子进程的终止状态信息(退出原因、退出码等)。
    返回值
        成功:终止子进程的PID。
        失败:返回-1

    通过wstatus可解析的信息包括:

    说明
    WIFEXITED(status) 子进程是否正常终止
    WEXITSTATUS(wstatus) 获取正常终止的退出状态码
    WIFSIGNALED(status) 子进程是否异常终止
    WTERMSIG(status) 获取导致异常终止的信号
    WCOREDUMP(status) 是否能产生core文件

    示例:

    #include <func.h>
    
    void print_wstatus(int status) {
        if (WIFEXITED(status)) {
            int exit_code = WEXITSTATUS(status);
            printf("exit_code = %d", exit_code);
        } else if (WIFSIGNALED(status)) {
            int signo = WTERMSIG(status);
            printf("term_sig = %d", signo);
    #ifdef WCOREDUMP
            if (WCOREDUMP(status)) {
                printf(" (core dump)");
            }
    #endif
        }
        printf("\n");
    }
    
    int main(int argc, char* argv[])
    {
        pid_t pid = fork();
        switch (pid) {
        case -1:
            error(1, errno, "fork");
        case 0:
            // 子进程
            printf("CHILD: pid = %d\n", getpid());
            // sleep(2);
            // return 123;
            // exit(96);
            // _exit(9);
            // abort();
            while (1);
        default:
            // 父进程
            int status; // 保存子进程的终止状态信息, 位图。
            pid_t childPid = wait(&status); // 阻塞点:一直等待,直到有子进程终止
            if (childPid > 0) {
                printf("PARENT: %d terminated\n", childPid);
                print_wstatus(status);
            }
            exit(0);
        }
        return 0;
    }
    
  2. waitpid函数等待指定PID的子进程。

    #include <sys/wait.h>
    pid_t waitpid(pid_t pid, int *wstatus, int options);
    
    参数
        pid:要等待的子进程的范围。
        wstatus:一个指向整数的指针,用于存储子进程的终止状态信息(退出原因、退出码等)。
        options:等待的行为选项。
    返回值
        成功:终止子进程的PID。
        	 如果设置WNOHANG,并且没有子进程修改状态,返回0。
        失败:返回-1

    pid不同取值的含义:

    pid 含义
    >0 等待指定的子进程
    -1 等待任意子进程
    0 等待同进程组的子进程
    <-1 等待指定进程组|pid|中的子进程

    options的选项:

    options 含义
    WNOHANG 非阻塞等待。若没有子进程终止,waitpid立即返回0,不阻塞父进程。
    WUNTRACED 等待被暂停的子进程(如因SIGSTOP信号暂停的进程)。
    WCONTINUED 等待从暂停状态恢复的子进程(如因SIGCONT信号恢复的进程)。

    因此,wait(&status)等价与waitpid(-1, &status, 0)

    示例:

    #include <func.h>
    
    void print_wstatus(int status) {
        if (WIFEXITED(status)) {
            int exit_code = WEXITSTATUS(status);
            printf("exit_code = %d", exit_code);
        } else if (WIFSIGNALED(status)) {
            int signo = WTERMSIG(status);
            printf("term_sig = %d", signo);
    #ifdef WCOREDUMP
            if (WCOREDUMP(status)) {
                printf(" (core dump)");
            }
    #endif
        }
        printf("\n");
    }
    
    int main(int argc, char* argv[])
    {
        pid_t pid = fork();
        switch (pid) {
        case -1:
            error(1, errno, "fork");
        case 0:
            // 子进程
            printf("CHILD: pid = %d\n", getpid());
            // sleep(2);
            // return 123;
            // exit(96);
            // _exit(9);
            // abort();
            while (1);
        default:
            // 父进程
            int status; // 保存子进程的终止状态信息, 位图。
            pid_t childPid = waitpid(-1, &status, WNOHANG); // 阻塞点:一直等待,直到有子进程终止
            if (childPid > 0) {
                printf("PARENT: %d terminated\n", childPid);
                print_wstatus(status);
            } else if (childPid == 0) {
                printf("PARENT: no child changed state\n");
            } else {
                error(1, 0, "waitpid");
            }
            exit(0);
        }
        return 0;
    }
    

6.4.5 执行程序

6.4.5.1 exec*

exec*是一系列的系统调用。它们通常适用于在fork之后,将子进程的指令部分进行替换修改。

当进程执行到exec*系统调用的时候,它会将传入的指令来取代进程本身的代码段、数据段、栈和堆,然后将PC指针重置为新的代码段的入口。

exec*当中包括多个不同的函数,这些函数之间只是在传入参数上面有少许的区别。

#include <unistd.h>

extern char **environ;

int execl(const char *pathname, const char *arg, ...
                       /* (char  *) NULL */);vi
int execlp(const char *file, const char *arg, ...
                       /* (char  *) NULL */);
int execle(const char *pathname, const char *arg, ...
                       /*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
                       char *const envp[]);
参数
    pathname:可执行文件路径
    file:可执行文件名字
    arg/argv:可执行文件参数
返回值
    成功:不返回
    失败:-1,并设置errno

函数名的命名规则:

  • l(list):命令行参数以可变长参数指定,并以NULL结尾
  • p(path):会根据PATH环境变量查找可执行程序
  • e(environment):会替换环境变量
  • v(vector):命令行参数以数组的形式指定,并以NULL结尾

示例:

#include <func.h>

char* new_env[] = {"user=wowyummy", "aaa=hello", NULL};
char* args[] = {"./echoall", "aaa", "bbb", "ccc", NULL};

int main(int argc, char* argv[])
{
    printf("BEGIN\n");
    
    // execlp("echoall", "./echoall", "hello", "world", NULL);
    // execl("echoall", "./echoall", "hello", "world", NULL);
    // execle("echoall", "./echoall", "hello", "world", NULL, new_env);
    execve("echoall", args, new_env);
    printf("END\n"); // 看不到
    return 0;
}
  1. 环境变量

    环境变量是操作系统中在系统或进程运行时动态存储配置信息的键值对,用于为应用程序、脚本或系统进程提供运行时参数,影响其行为逻辑(如路径查找、语言设置、权限控制等)。

    查看命令:

    $env			#查看所有环境变量
    $echo $PATH		#可以查看PATH环境变量的内容
    

    子进程会继承父进程的环境变量。

    #include <func.h>
    
    int main(int argc, char* argv[])
    {
        printf("pid = %d, ppid = %d\n", getpid(), getppid());
    
        // 打印命令行参数
        printf("Arguments: ");
        for(int i = 0; i < argc; i++) {
            printf("    %s\n", argv[i]);
        }
    
        // 打印环境变量
        printf("Environments: ");
        extern char** environ; // 声明外部变量
        char** p = environ;
        while (*p != NULL) {
            printf("    %s\n", *p);
            p++;
        }
        return 0;
    }
    
  2. exec的原理

    • 清除进程的代码段、数据段、堆、栈、上下文
    • 加载新的可执行程序(设置代码段、数据段)
    • 从新的可执行程序的main函数第一行开始执行

    示例:

    #include <func.h>
    
    int main(int argc, char* argv[])
    {
        printf("pid = %d, ppid = %d\n", getpid(), getppid());
    
        // 执行程序(环境变量的程序)
        execl("echoall", "./echoall", "aaa", "bbb", NULL);
    
        error(1, errno, "execl");
        return 0;
    }
    
    运行结果:
    pid = 15792, ppid = 12354
    pid = 15792, ppid = 12354
    Arguments:     ./echall
        aaa
        bbb
    Environments:     SHELL=/bin/bash
        LC_ADDRESS=zh_CN.UTF-8
        LC_NAME=zh_CN.UTF-8
    
6.4.5.2 system

system函数可以创建一个新进程,新进程使用shell脚本执行传入的命令。

#include <stdlib.h>

int system(const char *command);
参数
    command:一个以null结尾的字符串,表示要在shell中执行的命令
返回值:
    若command为NULL,如果shell可用,则返回一个非零值,如果没有可用的shell,则返回0。
    若如果无法创建子进程,或无法检索其状态,则返回-1,并设置errno。
    若无法在子进程中执行shell,则返回值就像子进程调用_exit(2)。
    若所有系统调用都成功,则返回值是用于执行的子shell的终止状态命令。

示例:

#include <func.h>

int my_system(const char* cmd) {
    pid_t pid = fork();
    switch(pid) {
    case -1:
        error(1, errno, "fork");
    case 0:
        // 子进程执行新的可执行程序
        execlp("sh", "sh", "-c", cmd, NULL);
        error(1, errno, "exelp");
    default:
        // 父进程
        waitpid(pid, NULL, 0);
    }
}

int main(int argc, char* argv[])
{
    // system("top");
    my_system("top");
    return 0;
}

// 运行结果:
// 在终端中启动top命令

惯用法:

  • 先调用fork()
  • 子进程执行新的可执行程序
  • 父进程等待子进程结束

6.5 进程间通信

虚拟CPU和虚拟内存的引入保证了进程的一个重要特性就是隔离,一个进程在执行过程中总是认为自己占用了所有的CPU和内存,但是实际在底层,操作系统和硬件完成了很多工作才实现了隔离的特性(比如内核和时钟设备配合实现进程调度)。在多个进程之间,如果需要进行通信的话,隔离特性会造成一些通信的障碍。所以我们需要一些手段来跨越隔离,实现进程间通信(InterProcess Communication, IPC)。

6.5.1 管道

6.5.1.1 特性
  • 为了支持可移植性,管道是半双工的,若要求全双工通信,则须使用两根管道;
  • 管道的读端和写端必须同时就绪,才能通信;
  • 如果写端关闭,读端是可以读到剩余数据;如果数据读完了,读端会读到EOF (read会返回 0);
  • 如果读端关闭,往管道写数据,内核发送SIGPIPE信号。
6.5.1.2 有名管道

使用mkfifo命令可以创建有名管道文件:

$ mkfifo pipe1

#通过ls -l命令可以查看:
prw-rw-r-- 1 wowyummy wowyummy     0 115 11:57 pipe1

操作有名管道的方式与操作文件一样。

  • 示例实现半双工通信:

    对P1端(写):

    #include <func.h>
    
    int main(int argc, char* argv[])
    {
        int fd = open("pipe1", O_WRONLY);// 阻塞点
        if (fd == -1) {
            error(1, errno, "open pipe1");
        }
    
        printf("Established\n");
    
        sleep(5);
    
        write(fd, "Hello from FIFO\n", 16);
        return 0;
    }
    

    阻塞点:管道需要读端和写端都就绪,open才会返回。

    对P2端(读):

    #include <func.h>
    
    int main(int argc, char* argv[])
    {
        int fd = open("pipe1", O_RDONLY);
        if (fd == -1) {
            error(1, errno, "open pipe1");
        }
    
        printf("Established\n");
    
        char buf[1024];
        read(fd, buf, sizeof(buf));// 阻塞点
        
        printf("p2: %s\n", buf);    return 0;
    }
    

    阻塞点:当写端写入数据时,read才会返回。

  • 示例实现全双工通信:

    对P1端:

    #include <func.h>
    
    int main(int argc, char* argv[])
    {
        int fd1 = open("pipe1", O_WRONLY);// 阻塞点
        if (fd1 == -1) {
            error(1, errno, "open pipe1");
        }
    
        int fd2 = open("pipe2", O_RDONLY);
        if (fd2 == -1) {
            error(1, errno, "open pipe2");
        }
    
        printf("Established\n");    return 0;
    }
    

    对P2端:

    #include <func.h>
    
    int main(int argc, char* argv[])
    {
        int fd1 = open("pipe1", O_RDONLY);// 阻塞点
        if (fd1 == -1) {
            error(1, errno, "open pipe1");
        }
    
        int fd2 = open("pipe2", O_WRONLY);
        if (fd2 == -1) {
            error(1, errno, "open pipe2");
        }
    
        printf("Established\n");
        return 0;
    }
    

    在这种情况下,可能会出现死锁现象。

  • 示例死锁:

    对P1端:

    #include <func.h>
    
    #define MAXLINE 256
    
    int main(int argc, char* argv[])
    {
        int fd1 = open("pipe1", O_WRONLY);
        if (fd1 == -1) {
            error(1, errno, "open pipe1");
        }
    
        int fd2 = open("pipe2", O_RDONLY);
        if (fd2 == -1) {
            error(1, errno, "open pipe2");
        }
    
        printf("Established\n");
    
        char recvline[MAXLINE];
        char sendline[MAXLINE];
    
        while (fgets(sendline, MAXLINE, stdin) != NULL) {// 阻塞点1
            write(fd1, sendline, strlen(sendline));
            read(fd2, recvline, MAXLINE);// 阻塞点2
            printf("from p2: %s\n", recvline);
        }
    
        close(fd1);
        close(fd2);
        
        return 0;
    }
    

    对P2端:

    #include <func.h>
    
    #define MAXLINE 256
    
    int main(int argc, char* argv[])
    {
        int fd1 = open("pipe1", O_RDONLY);
        if (fd1 == -1) {
            error(1, errno, "open pipe1");
        }
    
        int fd2 = open("pipe2", O_WRONLY);
        if (fd2 == -1) {
            error(1, errno, "open pipe2");
        }
    
        printf("Established\n");
    
        char recvline[MAXLINE];
        char sendline[MAXLINE];
    
        while (fgets(sendline, MAXLINE, stdin) != NULL) {// 阻塞点1
            write(fd2, sendline, strlen(sendline));
            read(fd1, recvline, MAXLINE);// 阻塞点2
            printf("from p1: %s\n", recvline);
        }
    
        close(fd1);
        close(fd2);
        
        return 0;
    }
    

    产生死锁的原因:一个执行流程有多个阻塞点。

6.5.1.3 无名管道

使用系统调用pipe可以创建匿名管道,匿名管道只使用于存在亲缘关系的进程之间进行通信。

#include <unistd.h>

int pipe(int pipefd[2]);
参数
    pipefd[2]:存储管道的文件描述符,pipefd[0]为读端,pipefd[1]为写端。
返回值
    成功:0。
    失败:-1,并设置errno。

匿名管道的原理:

匿名管道

  • 父进程调用pipe(pipefd),内核创建管道并分配读、写端的文件描述符。
  • 父进程调用fork()创建子进程,子进程复制父进程的文件描述符表,因此子进程也能访问这两个管道描述符。
  • 父子进程通过关闭不需要的端,实现单向通信。

惯用法:

  1. 先调用pipe()创建无名管道;
  2. 再调用fork()创建子进程;
  3. 父进程关闭管道的一端;
  4. 子进程关闭管道的另一端。

示例:

#include <func.h>

int main(int argc, char* argv[])
{
    // 1. 先 pipe() 
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        error(1, errno, "pipe");
    }

    // 2. 后 fork()
    char buf[1024];

    switch (fork()) {
    case -1:
        error(1, errno, "fork");
    case 0:
        // 3. 子进程关闭管道的另一端
        close(pipefd[1]);
        read(pipefd[0], buf, 1024);
        printf("Child: %s\n", buf);
        exit(0);
    default:
        // 4. 父进程关闭管道的一端
        close(pipefd[0]);
        sleep(2);
        write(pipefd[1], "Hello from parent", 18);
        exit(0);
    }

    return 0;
}
6.5.1.4 I/O模型的类型
  • 阻塞I/O
  • 非阻塞I/O(轮询)
  • I/O多路复用(监听多个I/O时间),将多个阻塞点变成一个阻塞点
    • select
    • poll
    • epoll
  • 信号驱动I/O
  • 异步I/O
6.5.1.5 select

select函数是IO多路复用的函数,主要用来等文件描述符中的事件是否就绪,select可以使我们在同时等待多个文件缓冲区 ,减少IO等待的时间,能够提高进程的IO效率。

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
参数
    nfds:监听的最大文件描述符+1;
    readfds:传入时表示对哪些文件描述符读事件感兴趣,传出时表示读事件已就绪的文件描述符;
    writefds:传入时表示对哪些文件描述符写事件感兴趣,传出时表示写事件已就绪的文件描述符;
    exceptfds:传入时表示对哪些文件描述符发生异常感兴趣,传出时表示发生异常的文件描述符;
    timeout:超时时间,即最多阻塞的时间长度。传入时表示超时时间,传出时表示还剩多长时间。
返回值
    成功:若未超时返回就绪时间的数目,若超时返回0;
    失败:-1,并设置errno。

fd_set类型是一个1024位的位图,位图的位置表示相对应的文件描述符,内容表示该文件描述符是否有效,1代表该位置的文件描述符有效,0则表示该位置的文件描述符无效。对fd_set的操作如下:

void FD_CLR(int fd, fd_set *set);		// 将对应位设置为0(将fd从set中移除)
int  FD_ISSET(int fd, fd_set *set);		// 检查对应位是否为1(检查fd是否在set中)
void FD_SET(int fd, fd_set *set);		// 将对应位设置为1(将fd添加到set中)
void FD_ZERO(fd_set *set);				// 将所有未清零(初始化set)

struct timeval的定义为:

struct timeval {
	long tv_sec;         /* seconds */
 	long tv_usec;        /* microseconds */
};

应用进程和内核都需要从readfdswritefds获取信息,其中内核需要从readfdswritefds知道哪些文件描述符需要等待,应用进程需要从readfdswritefds中知道哪些文件描述符的事件就绪。如果我们要不断轮询等待文件描述符,则应用进程需要不断的重新设置readfdswritefds,因为每一次调用select,内核会修改readfdswritefds,所以我们需要在应用程序中设置一个数组来保存程序需要等待的文件描述符。

select示意图

示例,用select实现一个点对点聊天程序:

对P1端:

#include <func.h>

#define MAXLINE 256

int main(int argc, char* argv[])
{
    int fd1 = open("pipe1", O_WRONLY);
    if (fd1 == -1) {
        error(1, errno, "open pipe1");
    }

    int fd2 = open("pipe2", O_RDONLY);
    if (fd2 == -1) {
        error(1, errno, "open pipe2");
    }

    printf("Established\n");

    char recvline[MAXLINE];
    char sendline[MAXLINE];

    fd_set mainfds;
    FD_ZERO(&mainfds);
    FD_SET(STDIN_FILENO, &mainfds);
    int maxfds = STDIN_FILENO;

    FD_SET(fd2, &mainfds);
    if (fd2 > maxfds) {
        maxfds = fd2;
    }

    for (;;) {
        fd_set readfds = mainfds;   // 结构体的复制

        // struct timeval timeout = {5, 0};
        // int events = select(maxfds + 1, &readfds, NULL, NULL, &timeout);
        int events = select(maxfds + 1, &readfds, NULL, NULL, NULL);
        switch (events) {
        case -1:
            error(1, errno, "select");
        case 0:
            // 超时
            printf("TIMEOUT\n");
            continue;
        default:
            // 打印 timeout 的值
            /* printf("timeout: tv_sec = %ld, tv_usec = %ld\n", */
            /*        timeout.tv_sec, timeout.tv_usec); */

            // STDIN_FILENO 就绪
            if (FD_ISSET(STDIN_FILENO, &readfds)) {
                // 一定不会阻塞
                fgets(sendline, MAXLINE, stdin);
                // memset(sendline, 0, MAXLINE);
                write(fd1, sendline, strlen(sendline) + 1); // +1: for '\0'
            }
            // pipe2就绪
            if (FD_ISSET(fd2, &readfds)) {
                // 一定不会阻塞
                int nbytes = read(fd2, recvline, MAXLINE);
                if (nbytes == 0) {
                    // 管道的写端关闭了
                    goto end;
                } else if (nbytes == -1) {
                    error(1, errno, "read pipe2");
                }
                printf("from p2: %s", recvline);
            }
        }
    }

end:
    close(fd1);
    close(fd2);
    
    return 0;
}

对P2端:

#include <func.h>

#define MAXLINE 256

int main(int argc, char* argv[])
{
    int fd1 = open("pipe1", O_RDONLY);
    if (fd1 == -1) {
        error(1, errno, "open pipe1");
    }

    int fd2 = open("pipe2", O_WRONLY);
    if (fd2 == -1) {
        error(1, errno, "open pipe2");
    }

    printf("Established\n");

    char recvline[MAXLINE];
    char sendline[MAXLINE];

    fd_set mainfds;
    FD_ZERO(&mainfds);
    FD_SET(STDIN_FILENO, &mainfds);
    int maxfds = STDIN_FILENO;

    FD_SET(fd1, &mainfds);
    if (fd1 > maxfds) {
        maxfds = fd1;
    }

    for (;;) {
        fd_set readfds = mainfds;   // 结构体的复制

        // struct timeval timeout = {5, 0};
        // int events = select(maxfds + 1, &readfds, NULL, NULL, &timeout);
        int events = select(maxfds + 1, &readfds, NULL, NULL, NULL);
        switch (events) {
        case -1:
            error(1, errno, "select");
        case 0:
            // 超时
            printf("TIMEOUT\n");
            continue;
        default:
            /* printf("timeout: tv_sec = %ld, tv_usec = %ld\n", */
            /*        timeout.tv_sec, timeout.tv_usec); */

            // STDIN_FILENO 就绪
            if (FD_ISSET(STDIN_FILENO, &readfds)) {
                // 一定不会阻塞
                fgets(sendline, MAXLINE, stdin);
                write(fd2, sendline, strlen(sendline) + 1); // +1 for '\0'
            }
            // pipe1就绪
            if (FD_ISSET(fd1, &readfds)) {
                // 一定不会阻塞
                int nbytes = read(fd1, recvline, MAXLINE);
                if (nbytes == 0) {
                    // 管道的写端关闭的
                    goto end;
                } else if (nbytes == -1) {
                    error(1, errno, "read pipe1");
                }
                printf("from p1: %s", recvline);
            }
        }
    }

end:
    close(fd1);
    close(fd2);
    
    return 0;
}

6.5.2 信号

信号是Linux系统提供的一种向指定进程发送特定事件的方式,进程会对信号进行识别和处理。

信号的产生是异步的,即一个进程不知道自己何时会收到信号,在收到信号之前进程只能一直在处理自己的任务。

6.5.2.1 事件源
  • 硬件

    访问非法内存(段错误):SIGSEGV(segment volation)

    执行非法的指令:SIGILL(illegal)

    分母为0(算数异常):SIGFPE(float point exception)

  • 内核

    读端关闭时写管道:SIGPIPE

  • 应用程序

    异常终止(abort):SIGABRT

    子进程终止:SIGCHLD

  • 用户

    ctrl+c:SIGINT

    ctrl+\:SIGQUIT

    ctrl+z:SIGTSTP

    kill命令

6.5.2.2 信号的执行流程

内核会感知事件,并给进程发送相应的信号,中间会有一段pending时间。

进程收到信号之后会立刻处理信号。

6.5.2.3 信号的默认处理方式

通过man 7 signal命令可查看信号相关信息。

信号的默认处理方式类型如下:

信号处理方式 含义
Term 终止进程
Ign 忽略该信号
Core 终止进程并转储核心(详见 core(5))
Stop 停止进程
Cont 如果进程当前处于停止状态,则继续运行该进程

常见信号及其默认处理方式如下:

信号 标准 默认处理方式 说明
SIGABRT P1990 Core 来自abort(3)的中止信号
SIGALRM P1990 Term 来自 alarm(2)的定时器信号
SIGBUS P2001 Core 总线错误(内存访问不良)
SIGCHLD P1990 Ign 子进程停止或终止
SIGCLD - Ign SIGCHLD的同义词
SIGCONT P1990 Cont 若进程停止则继续运行
SIGEMT - Term 仿真器陷阱
SIGFPE P1990 Core 浮点异常
SIGHUP P1990 Term 检测到控制终端挂起或控制进程死亡
SIGILL P1990 Core 非法指令
SIGINFO - Core SIGPWR的同义词
SIGINT P1990 Term 来自键盘的中断信号
SIGIO - Term I/O就绪(4.2BSD)
SIGIOT - Core IOT陷阱,SIGABRT的同义词
SIGKILL P1990 Term 终止信号(无法捕获、阻塞或忽略)
SIGLOST - Term 文件锁丢失(未使用)
SIGPIPE P1990 Term 管道断裂:向无读取者的管道写入;参见pipe(7)
SIGPOLL P2001 Term 可轮询事件(System V),SIGIO的同义词
SIGPROF P2001 Term 分析定时器过期
SIGPWR - Term 电源故障(System V)
SIGQUIT P1990 Core 来自键盘的退出信号
SIGSEGV P1990 Core 无效内存引用
SIGSTKFLT - Term 协处理器栈故障(未使用)
SIGSTOP P1990 Stop 停止进程(无法捕获、阻塞或忽略)
SIGTSTP P1990 Stop 来自终端的停止信号(通常是Ctrl+Z)
SIGSYS P2001 Core 错误的系统调用(SVR4);另见 seccomp(2)
SIGUNUSED - Core SIGSYS的同义词
SIGTERM P1990 Term 终止信号(可捕获)
SIGTRAP P2001 Core 跟踪/断点陷阱
SIGTTIN P1990 Stop 后台进程的终端输入
SIGTTOU P1990 Stop 后台进程的终端输出
SIGURG P2001 Ign 套接字上的紧急条件(4.2BSD)
SIGUSR1 P1990 Term 用户定义信号1
SIGUSR2 P1990 Term 用户定义信号2
SIGVTALRM P2001 Term 虚拟闹钟(4.2BSD)
SIGXCPU` P2001 Core CPU 时间限制超出(4.2BSD);参见 setrlimit(2)
SIGXFSZ P2001 Core 文件大小限制超出(4.2BSD);参见 setrlimit(2)
SIGWINCH - Ign 窗口大小调整信号(4.3BSD、Sun)

其中,信号SIGKILLSIGSTOP由用户自定义信号处理函数,无法被捕获、阻塞或忽略。

6.5.2.4 注册信号处理函数

signal函数用于设置信号处理函数,以指定进程收到某个信号时的行为。

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
参数
    signum:要设置处理行为的信号编号;
    handler:信号处理函数的指针。
返回值
    成功:返回之前该信号的处理函数指针;
    失败:返回SIG_ERR,并设置errno。
  • typedef void (*sighandler_t)(int);定义了一个函数指针类型,该函数指针指向的函数返回值为void,参数为int(表示信号编号),用于描述处理信号时要执行的函数的格式。

  • 参数handler有三种常见的取值:

    • 自定义函数指针:表示收到信号时执行my_handler
    • 常量SIG_IGN:表示忽略该信号;
    • 常量SIG_DFL:表示恢复信号的默认行为。

示例:

#include <func.h>

int main(int argc, char* argv[])
{
    printf("pid = %d\n", getpid());

    // 忽略 SIGINT 信号
    sighandler_t oldhandler = signal(SIGINT, SIG_IGN);
    if (oldhandler == SIG_ERR) {
        error(1, errno, "signal SIGINT");
    }

    sleep(10);

    printf("Wake up\n");
    
    signal(SIGINT, SIG_DFL);

    for(;;) {

    }
    return 0;
}

// 运行现象:
// 1.程序启动后,打印进程ID,然后进入10秒休眠。
// 2.休眠期间按下Ctrl+C,进程无反应(不会终止)。
// 3.10秒后打印Wake up,随后进入无限循环。
// 4.此时再按下 Ctrl+C,进程会立即终止(因为已恢复SIGINT的默认行为)。
#include <func.h>

void handler(int signo) {
    switch (signo) {
    case SIGKILL:
        printf("Caught SIGKILL\n");
        break;
    case SIGSTOP:
        printf("Caught SIGSTOP\n");
        break;
    }
}

int main(int argc, char* argv[])
{
    printf("pid = %d\n", getpid());

    sighandler_t oldhandler = signal(SIGKILL, handler);
    if (oldhandler == SIG_ERR) {
        error(0, errno, "signal SIGKILL");
    }


    oldhandler =  signal(SIGSTOP, handler);
    if (oldhandler == SIG_ERR) {
        error(0, errno, "signal SIGSTOP");
    }

    for(;;) {

    }

    return 0;
}

// 运行现象:
// 对SIGKILL和SIGSTOP的自定义处理函数设置失败,且这两个信号仍保持默认行为(强制终止/暂停),不会触发代码中的printf输出。
6.5.2.5 发送信号
6.5.2.5.1 kill
  1. kill命令可用于向进程发送信号。

    kill [选项] <信号编号/名称> <进程ID(PID)>
    

    若省略信号参数,默认发送SIGTERM(信号 15),请求进程终止。

    示例:

    信号编号 信号名称 含义 示例(假设进程 PID 为 1234)
    1 SIGHUP 挂断信号,常用于重启进程 kill -1 1234kill -SIGHUP 1234
    9 SIGKILL 强制终止进程 kill -9 1234kill -SIGKILL 1234
    15 SIGTERM 终止进程 kill 1234(默认发送SIGTERM
    18 SIGCONT 恢复被暂停的进程 kill -18 1234kill -SIGCONT 1234
    19 SIGSTOP 暂停进程 kill -19 1234kill -SIGSTOP 1234
    2 SIGINT 中断信号 kill -2 1234kill -SIGINT 1234
  2. kill系统调用用于向指定进程(或进程组)发送信号。

    #include <sys/types.h>
    #include <signal.h>
    
    int kill(pid_t pid, int sig);
    参数
        pid:指定信号的接收对象;
        sig:要发送的信号编号。
    返回值
        成功:0;
        失败:-1,并设置errno。
    

    pid不同取值的含义:

    pid 含义
    >0 等待指定的子进程
    -1 等待任意子进程
    0 等待同进程组的子进程
    <-1 等待指定进程组|pid|中的子进程

    示例:

    #include <func.h>
    
    int main(int argc, char* argv[])
    {
        if (argc < 3) {
            error(1, 0, "Usage: %s signo pid...", argv[0]);
        }
    
        int signo;
        sscanf(argv[1], "%d", &signo);
        
        for (int i = 2; i < argc; i++) {
            pid_t pid;
            sscanf(argv[i], "%d", &pid);
    
            if (kill(pid, signo) == -1) {
                error(0, errno, "kill(%d, %d)", pid, signo);
            }
        }
        return 0;
    }
    
6.5.2.5.2 raise

raise函数用于向当前进程自身发送信号。

#include <signal.h>

int raise(int sig);
参数
    sig:要发送的信号编号。
返回值
    成功:0;
    失败:非零值。

raise函数等价于:

int kill(getpid(), sig);
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐