Lazy loaded image
🥳嵌入式Linux开发
Lazy loaded image进程和进程间通信
Words 14106Read Time 36 min
2024-11-27
2026-8-15
fork 用于创建一个新的进程。父子进程在逻辑上拥有独立的地址空间,Linux 一般通过写时复制来降低创建成本,所以不是一上来就把所有内存都完整复制。fork 后子进程会继承父进程的大部分执行环境,比如文件描述符、信号处理配置等,但它们已经是两个独立进程,后续各自运行。
一、介绍1.1 初学者必需知识1.2 Linux 进程是什么?1.2.1 进程环境变量1.2.2 进程的内存布局1.3 Linux 进程用在哪?二、Linux 进程怎么用?2.1 fork() 函数2.2 文件共享2.2.1 文件描述符继承,父子进程偏移量共享2.2.2 不同的文件描述符,父子进程偏移量不共享2.3 fork() 函数使用场景2.4 fork() 之后的竞争条件2.5 vfork() 函数2.5.1 为什么需要vfork()?2.5.2 函数接口2.5.3 vfork 为什么危险?现代系统还有没有必要用?2.6 进程的生命周期2.7 监视子进程和进程回收2.7.1 wait() 函数2.7.2 waitpid() 函数2.5.3 僵尸进程与孤儿进程2.8 执行新程序2.8.1 execve() 函数2.8.2 exec 库函数2.8.3 system()函数2.9 进程状态和关系2.9.1 进程状态2.9.2 进程关系2.10 守护进程2.10.1 何为守护进程?2.10.2 编写守护进程程序2.11 单例运行2.11.1 通过文件存在与否进行判断2.11.2 使用文件锁三、进程间通信简介3.1 为什么需要进程间通信3.2 进程通信来龙去脉3.2 怎么实现进程通信3.2.1 管道(pipe)3.2.2 信号(signal)3.2.3 消息队列(message queue)3.2.4 信号量( semophore )3.2.5 共享内存( shared memory )POSIX 共享内存标准接口示例:shm_open + mmap为什么共享内存最快?相比消息队列的短板是什么3.2.6 mmap 内存映射:匿名映射、文件映射与注意点1. mmap 为什么可能比 read/write 高效?2. 匿名 mmap vs 文件 mmap3. 匿名 mmap 做共享内存的边界4. MAP_PRIVATE 和 MAP_SHARED 不要混5. 文件 mmap 的持久化注意点6. mmap 的常见坑7. 面试回答模板3.2.7 套接字( socket )3.3 IPC 高频易错点总结引用

一、介绍

1.1 初学者必需知识

  1. 在 Linux 中输入可执行文件的相对路径或者绝对路径就可以运行程序,比如 ./app arg1 arg2,程序执行过程是,shell 进程解析命令行参数并将参数传递给加载器,加载器加载应用程序,将参数传递给引导代码(编译链接时引导代码和 main 函数一起链接到应用程序中),引导程序再调用 main() 函数,最终参数传递给 main() 函数。
  1. 程序的结束其实就是进程结束,分为正常终止和异常终止。终止可以注册终止处理函数
    1. 进程是一个可执行程序的实例,是一个动态过程,有启动、运行和终止生命周期。进程号(pid_t)是标志系统中某一个进程标志 。

    1.2 Linux 进程是什么?

    进程其实就是一个可执行程序的实例,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。
    Linux 系统下的每一个进程都有一个进程号(process ID,简称 PID),进程号是一个正数,用于唯一标识系统中的某一个进程。
    • getpid() 获取本进程进程号
      • getppid() 获取父进程进程号
         

        1.2.1 进程环境变量

        1. 每个进程都有一组与其相关的环境变量,以字符串形式存储在列表中。进程的环境变量是从父进程中继承过来的,譬如在 shell 终端下执行一个应用程序,那么该进程的环境变量就是从其父进程(shell 进程)中继承过来的。新的进程在创建之前,会继承其父进程的环境变量副本。
          命令
          说明
          env
          查看进程所有环境变量
          export LINUX_APP=123456
          添加环境变量
          export -n LINUX_APP
          删除环境变量
          应用程序中操作环境变量
          命令
          说明
          extern char **environ;
          environ 是一个全局变量,存储环境变量
          char *getenv(const char *name);
          获取某个指定的环境变量
          int putenv(char *string);
          添加/修改环境变量
          int setenv(const char *name, const char *value, int overwrite);
          添加/修改环境变量
          推荐,但会产生内存泄漏
          NAME=value ./app
          添加环境变量更简单方法
          int unsetenv(const char *name);
          移除参数 name 标识的环境变量
          int clearenv(void);
          清空环境变量
          environ = NULL;
          清空环境变量

          1.2.2 进程的内存布局

          • Stack (栈):存放局部变量和函数调用信息,从高地址向低地址增长
          • Heap (堆):动态内存分配区域,从低地址向高地址增长
          • BSS段:存放未初始化的全局/静态变量
          • Data段:存放已初始化的全局/静态变量
          • Text/Code段:存放程序执行指令

          1.3 Linux 进程用在哪?

          在诸多的应用中,创建多个进程是任务分解时行之有效的方法,譬如:
          • 某一网络服务器进程可在监听客户端请求的同时,为处理每一个请求事件而创建一个新的子进程,与此同时,服务器进程会继续监听更多的客户端连接请求。
          • 在一个大型的应用程序任务中,创建子进程通常会简化应用程序的设计,同时提高了系统的并发性(即同时能够处理更多的任务或请求,多个进程在宏观上实现同时运行)。

          二、Linux 进程怎么用?

          2.1 fork() 函数

          调用 fork()函数创建一个新的进程
          参数
          描述
          返回值
          成功:1. 父进程中返回子进程的PID 2. 子进程中返回值为0 失败: 父进程返回值-1,不创建子进程,并设置 errno
          注意1:调用后,存在两个进程,每个进程都会从fork()函数的返回处继续执行,会导致调用 fork()返回两次值,子进程返回一个值、父进程返回一个值。在程序代码中,可通过返回值来区分是子进程还是父进程 注意2:fork()调用成功后,子进程和父进程会继续执行 fork()调用之后的指令,子进程、父进程各自在自己的进程空间中运行。
          • 共享代码段
          • 子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,但不共享。
          示例:
          • 在调用了 fork()之后,父、子进程中一般只有一个会通过调用 exit()退出进程,而另一个则应使用_exit()退出。具体原因参考下文2.6 进程的生命周期
          ⚠️
          子进程创建开始,父子进程是共享数据段、堆、栈内存页的。只有在父子进程修改共享内存页,才会产生拷贝。如下过程:
          1. 创建子进程:父进程调用 fork(),操作系统为子进程创建页表,指向父进程的内存页,并将这些页标记为只读。
          1. 尝试写入:如果父进程或子进程尝试写入共享的内存页,会触发页错误(page fault)。
          1. 复制页:操作系统捕获页错误,复制该内存页,子进程的页表更新为指向新复制的页,在新页上执行写操作,父进程的页保持不变。

          2.2 文件共享

          2.2.1 文件描述符继承,父子进程偏移量共享

          输出结果
          AABBAABBAABBAABB1122112211221122

          2.2.2 不同的文件描述符,父子进程偏移量不共享

          输出结果
          1122112211221122AABB
          子进程重新打开,文件偏移量不共享,从头开始写入四次“1122”。

          2.3 fork() 函数使用场景

          fork()函数有以下两种用法:
          • 父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,调用 fork()创建一个子进程,使子进程去处理此请求、而父进程可以继续等待下一个服务请求。
          • 一个进程要执行不同的程序。譬如在程序 app1 中调用 fork()函数创建了子进程,此时子进程是要去执行另一个程序 app2,也就是子进程需要执行的代码是 app2 程序对应的代码,子进程将从 app2程序的 main 函数开始运行。这种情况,通常在子进程从 fork()函数返回之后立即调用 exec 族函数来实现,关于 exec 函数将在后面内容向大家介绍。

          2.4 fork() 之后的竞争条件

          问题说明
          调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行。
          解决方法
          采用信号同步技术,实现指定顺序执行。
          输出结果

          2.5 vfork() 函数

          2.5.1 为什么需要vfork()?

          存在问题
          1. fork() 复制父进程中的数据段和堆栈段中的绝大部分内容,耗时长、效率低、资源浪费
          1. 调用 exec 函数,子进程不再执行父程序中的代码段,而是执行新程序的代码段,再用fork(),存粹浪费时间、降低效率
          功能
          vfork()是为子进程立即执行 exec()新的程序而专门设计的,与fork() 函数主要有两个区别
          1. vfork() 函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或_exit),于是也就不会引用该地址空间的数据
          1. vfork() 保证子进程先运行,子进程调用 exec 之后父进程才可能被调度运行

          2.5.2 函数接口

          2.5.3 vfork 为什么危险?现代系统还有没有必要用?

          核心机制:vfork() 底层是 clone(CLONE_VM|CLONE_VFORK|SIGCHLD)——CLONE_VM 让子进程和父进程共用同一个 mm_struct(连页表都共用同一份物理页,不是 fork 那种“写时复制”),CLONE_VFORK 保证父进程被挂起,直到子进程调用 exec() 或 _exit() 才恢复运行。
          ⚠️
          容易搞混的两点: 1. vfork() 依然创建了一个新进程(新的 task_struct、新 PID)。“创建进程”和“是否共享地址空间”是两件独立的事,不能因为它共享了地址空间就否定它创建了新进程。 2. 危险窗口只存在于“vfork() 返回”到“子进程调用 exec()/_exit()”之间——这段时间父子进程字面上共用同一份物理内存和页表;子进程一旦 exec 成功会拿到全新的 mm_struct,但这一步替换并不能撤销此前已写入共享内存的修改,父进程恢复运行后看到的正是被污染后的值。
          反例
          子进程写 x 这一行,改的不是“自己的一份拷贝”,而是父进程恢复执行后还要读的同一块内存——这不是“父进程执行逻辑出错”,而是父进程的局部变量在自己毫不知情的情况下被外部写坏,属于未定义行为级别的 bug,不一定崩溃,但结果完全不可预期。
          为什么“立刻 exec 覆盖地址空间”救不了这个问题:exec() 只是把子进程自己后续要用的地址空间换成新程序的,这一步发生在危险窗口结束之后,无法、也不能撤销窗口期内已经写入共享内存的修改。vfork 语义也因此规定子进程在危险窗口里只能调用 _exit()exec() 系列函数:注意是 _exit() 不是 exit()——exit() 会刷新共享的 stdio 缓冲区、跑 atexit 钩子,这些都会碰共享状态,同样在禁止之列,实践中很容易在这两者之间踩坑。
          现代系统还有没有必要用:写时复制(COW)出现后,fork()+exec() 的开销已经和 vfork()+exec() 相差很小——因为 fork 之后到 exec 之前的这段代码几乎不会触发页写入,COW 基本不会真正复制物理页,fork() 唯一多付出的成本只是复制一份页表项,不是复制数据本身。因此现代应用代码不建议手写 vfork()(POSIX.1-2008 已将其标记为过时),应该用 posix_spawn() 代替——语义上更安全;posix_spawn() 的具体实现仍可能在受控场景下(比如地址空间很大的进程)内部使用 CLONE_VM|CLONE_VFORK 做优化,但这属于库代码该操心的事,不是普通业务逻辑该直接碰 vfork 的理由。

          2.6 进程的生命周期

          1. 进程开始:PID =1, init 进程,内核启动
          1. 进程终止:正常终止和异常终止
            1. exit() 终止执行步骤
              1. 如果程序中注册了进程终止处理函数,那么会调用终止处理函数
              2. 刷新 stdio 流缓冲区。
              3. 执行_exit()系统调用。
            2. 子进程使用_exit()退出、而父进程则使用 exit()退出。其原因就在于调用 exit()函数终止进程时会刷新进程的 stdio 缓冲区
          如上程序,由于子进程调用exit(0)退出程序,会刷新进程的stdio缓冲区,因此会造成Hello World!打印两遍。可以采用以下任一方法来避免重复的输出结果:
          1. 对于行缓冲设备,可以加上对应换行符,譬如 printf 打印输出字符串时在字符串后面添加\n 换行符,对于 puts()函数来说,本身会自动添加换行符;
          1. 在调用 fork()之前,使用函数 fflush()来刷新 stdio 缓冲区,当然,作为另一种选择,也可以使用setvbuf()和 setbuf() 来关闭 stdio 流的缓冲功能;
          1. 子进程调用_exit()退出进程、而非使用 exit(),调用_exit()在退出时便不会刷新 stdio 缓冲区,这也解释前面为什么我们要在子进程中使用_exit()退出这样做的一个原因。将示例中子进程的退出操作 exit()替换成_exit()进行测试,打印的结果便只会显示一次字符串。

          2.7 监视子进程和进程回收

          在很多应用程序的设计中,父进程需要知道子进程于何时被终止,并且需要知道子进程的终止状态信息,是正常终止、还是异常终止亦或者被信号终止等,意味着父进程会对子进程进行监视,本小节我们就来学习下如何通过系统调用 wait()以及其它变体来监视子进程的状态改变。

          2.7.1 wait() 函数

          获取子进程的终止状态信息
          • 若所有子进程都还在运行,阻塞等待子进程终止。
          • 没有子进程, wait()将返回错误,也就是返回-1、并且会将 errno 设置为 ECHILD。
          • 若已经有子进程终止,wait() 不会阻塞,会立即回收子进程的一些资源。
          参数
          描述
          status
          参数 status 用于存放子进程终止时的状态信息,参数 status 可以为 NULL,表示不接收子进程终止时的状态信息。
          返回值
          若成功则返回终止的子进程对应的进程号;失败则返回-1。errno 设置为 ECHILD
          可通过以下宏来检查 status 参数:
          • WIFEXITED(status):如果子进程正常终止,则返回 true;
          • WEXITSTATUS(status):返回子进程退出状态,是一个数值,其实就是子进程调用_exit()或 exit()时指定的退出状态;wait()获取得到的 status 参数并不是调用_exit()或 exit()时指定的状态,可通过WEXITSTATUS 宏转换;
          • WIFSIGNALED(status):如果子进程被信号终止,则返回 true;
          • WTERMSIG(status):返回导致子进程终止的信号编号。如果子进程是被信号所终止,则可以通过此宏获取终止子进程的信号;
          • WCOREDUMP(status):如果子进程终止时产生了核心转储文件,则返回 true;
          wait() 缺点
          1. 只能按照顺序处理进程,根据进程终止顺序
          1. 只能阻塞等待
          1. 只能发现被终止进程,对于SIGSTOP信号(暂停运行),SIGCONT(恢复执行)情况就无能为力了。

          2.7.2 waitpid() 函数

           
          参数
          描述
          pid
          参数 pid 用于表示需要等待的某个具体子进程 ⚫ 如果 pid 大于 0,表示等待进程号为 pid 的子进程; ⚫ 如果 pid 等于 0,则等待与调用进程(父进程)同一个进程组的所有子进程; ⚫ 如果 pid 小于-1,则会等待进程组标识符与 pid 绝对值相等的所有子进程; ⚫ 如果 pid 等于-1,则等待任意子进程。wait(&status)与 waitpid(-1, &status, 0)等价。
          status
          参数 status 用于存放子进程终止时的状态信息,参数 status 可以为 NULL,表示不接收子进程终止时的状态信息。
          options
          一个位掩码 1. WNOHANG:如果子进程没有发生状态改变(终止、暂停),则立即返回,也就是执行非阻塞等待。 2. WUNTRACED:除了返回终止的子进程的状态信息外,还返回因信号而停止(暂停运行)的子进程状态信息; 3. WCONTINUED:返回那些因收到 SIGCONT 信号而恢复运行的子进程的状态信息。
          返回值
          若成功则返回终止的子进程对应的进程号;失败则返回-1。0 时表示没有发生改变
          示例
          输出结果
           

          2.5.3 僵尸进程与孤儿进程

          孤儿进程:父进程先于子进程结束,在 Linux 系统当中,所有的孤儿进程都自动成为 init 进程(进程号为 1)的子进程。
          • 子进程调用 getppid()将返回 1,init 进程变成了孤儿进程的“养父”;这是判定某一子进程的“生父”是否还“在世”的方法之一
           
          僵尸进程:子进程先于父进程结束,此时父进程还未来得及给子进程“收尸”,那么此时子进程就变成了一个僵尸进程。
          • 当父进程调用 wait()(或其变体,下文不再强调)为子进程“收尸”后,僵尸进程就会被内核彻底删除。
          • 另外一种情况,如果父进程并没有调用 wait()函数然后就退出了,那么此时 init 进程将会接管它的子进程并自动调用 wait(),故而从系统中移除僵尸进程。
          如果父进程创建了某一子进程,子进程已经结束,而父进程还在正常运行,但父进程并未调用wait()回收子进程,此时子进程变成一个僵尸进程。首先来说,这样的程序设计是有问题的,如果系统中存在大量的僵尸进程,它们势必会填满内核进程表,从而阻碍新进程的创建。需要注意的是,僵尸进程是无法通过信号将其杀死的,即使是“一击必杀”信号SIGKILL也无法将其杀死,那么这种情况下,只能杀死僵尸进程的父进程(或等待其父进程终止),这样init进程将会接管这些僵尸进程,从而将它们从系统中清理掉!所以,在我们的一个程序设计中,一定要监视子进程的状态变化,如果子进程终止了,要调用wait()将其回收,避免僵尸进程。
           
          notion image
          状态栏显示的是“Z”(zombie,僵尸),表示它是一个僵尸进程。
           
          SIGCHLD 信号
          • 子进程状态改变时(终止、暂停或恢复),父进程会收到 SIGCHLD 信号
          • 进程的终止属于异步事件
            • → 捕获 SIGCHLD 信号,绑定信号处理函数,调用 wait()收回子进程
          • 当 SIGCHLD 信号处理函数正在为一个终止的子进程“收尸”时,如果相继有两个子进程终止,即使产生了两次 SIGCHLD 信号,父进程也只能捕获到一次 SIGCHLD 信号,结果是,父进程的SIGCHLD 信号处理函数每次只调用一次wait(),那么就会导致有些僵尸进程成为“漏网之鱼”。
            • → 循环非阻塞方式调用 waitpid()
          示例程序

          2.8 执行新程序

          2.8.1 execve() 函数

           
          参数
          描述
          filename
          参数 filename 指向需要载入当前进程空间的新程序的路径名,既可以是绝对路径、也可以是相对路径。
          argv
          参数argv则指定了传递给新程序的命令行参数。是一个字符串数组,该数组对应于main(intargc,char*argv[])函数的第二个参数argv,且格式也与之相同,是由字符串指针所组成的数组,以NULL结束。 argv[0]对应的便是新程序自身路径名。
          envp
          参数envp其实对应于新程序的environ数组 所指向的字符串格式为 name=value
          返回值
          execve 调用成功将不会返回;失败将返回-1,并设置 errno。

          2.8.2 exec 库函数

           
          • execv()的argv 参数与 execve()的 argv 参数相同,也是字符串指针数组;
          • execl()把参数列表依次排列,使用可变参数形式传递,本质上也是多个字符串,以 NULL 结尾
          • execlp()和 execvp()在 execl()和 execv()基础上加了一个 p,这个 p 其实表示的是 PATH;execlp()和execvp()则允许只提供新程序文件名,系统会在由环境变量PATH所指定的目录列表中寻找相应的可执行文件,如果执行的新程序是一个Linux命令,这将很有用
          • execle()和 execvpe()这两个函数在命名上加了一个 e,这个 e 其实表示的是 environment 环境变量,意味着这两个函数可以指定自定义的环境变量列表给新程序

          2.8.3 system()函数

          执行任意 shell 命令
          参数
          描述
          filename
          参数 command 指向需要执行的 shell 命令,以字符串的形式提供,譬如"ls -al"、"echo HelloWorld"等。
          返回值
          ⚫ 当参数 command 为 NULL,如果 shell 可用则返回一个非 0 值,若不可用则返回 0;针对一些非UNIX 系统,该系统上可能是没有 shell 的,这样就会导致 shell 不可能;如果 command 参数不为NULL,则返回值从以下的各种情况所决定。 ⚫ 如果无法创建子进程或无法获取子进程的终止状态,那么 system()返回-1; ⚫ 如果子进程不能执行 shell,则 system()的返回值就好像是子进程通过调用_exit(127)终止了; ⚫ 如果所有的系统调用都成功,system()函数会返执行 command 的 shell 进程的终止状态。

          2.9 进程状态和关系

          2.9.1 进程状态

          Linux 系统下进程通常存在 6 种不同的状态,分为:就绪态、运行态、僵尸态、可中断睡眠状态(浅度睡眠)、不可中断睡眠状态(深度睡眠)以及暂停态。
          notion image

          2.9.2 进程关系

          介绍完进程状态之后,接下来聊一聊进程关系,在 Linux 系统下,每个进程都有自己唯一的标识:进程号(进程 ID、PID),也有自己的生命周期,进程都有自己的父进程、而父进程也有父进程,这就形成了一个以 init 进程为根的进程家族树;当子进程终止时,父进程会得到通知并能取得子进程的退出状态。
          除此之外,进程间还存在着其它一些层次关系,譬如进程组和会话;所以,由此可知,进程间存在着多种不同的关系,主要包括:无关系(相互独立)、父子进程关系、进程组以及会话。
          1. 无关系
          1. 父子进程关系 fork() 创建
          1. 进程组:是一个或多个进程的集合,这些进程并不是孤立的,它们彼此之间或者存在父子、兄弟关系,或者在功能上有联系。方便对进程进行管理。
            1. 获取进程组ID
              程序输出:
              进程组 ID<23>---getpgrp() 进程组 ID<23>---getpgid(0) 进程组 ID<23>---getpgid(23)
              设置进程组ID
              程序输出:
              更改前进程组 ID<32> 更改后进程组 ID<32>
          1. 会话:会话是一个或多个进程组的集合
            1. 每个会话都有一个会话首领(leader),即创建会话的进程。会话的首领进程的进程组 ID 将作为该会话的标识,也就是会话 ID(sid)。
              当用户在某个终端登录时,一个新的会话就开始了;当我们在 Linux 系统下打开了多个终端窗口时,实际上就是创建了多个终端会话。

          2.10 守护进程

          2.10.1 何为守护进程?

          守护进程(Daemon)也称为精灵进程,是运行在后台的一种特殊进程。其特点如下:
          1. 不受用户登录注销的影响,一直运行着、直到系统关机。
          1. 脱离终端在后台运行。
          守护进程是一种很有用的进程。Linux 中大多数服务器就是用守护进程实现的,譬如,Internet 服务器inetd、Web 服务器 httpd 等。同时,守护进程完成许多系统任务,譬如作业规划进程 crond 等。

          2.10.2 编写守护进程程序

          1. 创建子进程、终止父进程
            1. 子进程拥有自己PID,保证了子进程不是一个进程组的组长进程
          1. 子进程调用 setsid 创建会话
            1. 调用 setsid()会使得子进程创建一个新的会话,子进程成为新会话的首领进程,同样也创建了新的进程组、子进程成为组长进程,此时创建的会话将没有控制终端。
            2. 让子进程摆脱原会话的控制、让子进程摆脱原进程组的控制和让子进程摆脱原控制终端的控制。
            3. 使子进程完全独立出来,从而脱离所有其他进程的控制。
          1. 将工作目录更改为根目录
            1. 子进程是继承了父进程的当前工作目录
            2. 将子进程工作目录更改为根目录,方便后期使用。
          1. 重设文件权限掩码 umask
            1. fork 函数新建的子进程继承了父进程的文件权限掩码,这就给子进程使用文件带来了诸多的麻烦
            2. 把文件权限掩码设置为 0,确保子进程有最大操作权限、这样可以大大增强该守护进程的灵活性。
          1. 关闭不再需要的文件描述符
            1. 子进程继承了父进程的所有文件描述符,这些被打开的文件可能永远不会被守护进程读或写,但它们一样消耗系统资源,可能导致所在的文件系统无法卸载
          1. 将文件描述符号为 0、1、2 定位到/dev/null
            1. 将守护进程的标准输入、标准输出以及标准错误重定向到/dev/null
            2. 这使得守护进程的输出无处显示、也无处从交互式用户那里接收输入。
          1. 其它:忽略 SIGCHLD 信号
            1. 并发服务器进程,如果父进程 wait 等待子进程退出,将又会增加父进程的负担、也就是增加服务器的负担,影响服务器进程的并发性能。
            2. 可以将 SIGCHLD 信号的处理方式设置为SIG_IGN,也就是忽略该信号,可让内核将僵尸进程转交给 init 进程去处理,这样既不会产生僵尸进程、又省去了服务器进程回收子进程所占用的时间。

          2.11 单例运行

          单例运行指程序只能被执行一次,只要该程序没有结束,就无法再次运行。对于某些场景下需要实现单例运行模式,譬如系统中守护进程,这些守护进程一般都是服务器进程,服务器程序只需要运行一次即可,能够在系统整个的运行过程中提供相应的服务支持,多次同时运行并没有意义、甚至还会带来错误!

          2.11.1 通过文件存在与否进行判断

          用一个文件的存在与否来做标志,在程序运行正式代码之前,先判断一个特定的文件是否存在。
          • 如果存在则表明进程已经运行,此时应该立马退出;
          • 如果不存在则表明进程没有运行,然后创建该文件,当程序结束时再删除该文件即可!
          • 以 O_RDONLY | O_CREAT | O_EXCL 的方式打开文件,如果文件不存在则创建文件,如果文件存在则 open 会报错返回-1。
          • 使用 atexit 注册进程终止处理函数,当程序退出时,使用 remove()删除该文件。
          这种方法操作简单,但应用中可能会出现以下问题:
          1. 若使用_exit() 退出。不执行回调,则无法执行 delete_file() 函数。
          1. 若程序异常退出,无法执行进程终止处理函数。
          1. 掉电关机,重启后文件仍存在,导致程序无法执行。
          针对第一种情况可以通过使用exit()函数退出,第三种,可以将文件放置到系统/tmp 目录下,/tmp 是一个临时文件系统,当系统重启之后/tmp 目录下的文件就会被销毁。针对第二种,有一种解决办法便是设置信号处理方式为忽略信号,或者针对某些信号注册信号处理函数,譬如 SIGTERM、SIGINT 等,在信号处理函数中删除文件然后再退出进程。但依然有个问题,并不是所有信号都可被忽略或捕获的,譬如SIGKILL 和 SIGSTOP,这两个信号是无法被忽略和捕获的

          2.11.2 使用文件锁

          第一种方法不太可靠,下面介绍使用文件锁方式来实现单例运行。
          文件存在判断法的根本问题在于,它把“程序是否正在运行”等价为“某个标志文件是否存在”。但文件只是磁盘上的静态状态,并不能准确反映进程的运行状态。程序如果通过 _exit() 退出、异常崩溃、被 SIGKILL 杀死,或者系统掉电重启,都可能导致标志文件残留,从而使下一次启动误判为程序已经运行。
          文件锁则不同。文件锁由内核维护,并与进程打开的文件描述符相关联。当进程退出或文件描述符被关闭时,内核会自动释放该进程持有的文件锁。因此,即使程序异常退出,锁也会随进程资源回收而释放。重启后虽然 .pid 文件可能仍然存在,但原来的锁已经不存在,程序仍然可以重新获取锁并覆盖旧的 PID 信息。
          所以,使用文件锁时,.pid 文件是否存在并不是判断程序是否运行的依据;真正的判断依据是当前进程能否成功获取该文件上的互斥锁。文件只是锁的载体,锁才是单例运行的核心机制。
          文件锁方式同样要通过一个特定的文件来实现,当程序启动之后,首先打开该文件,调用 open 时一般使用O_WRONLY | O_CREAT 标志,当文件不存在则创建该文件。
          然后尝试去获取文件锁
          • 若是成功获取,则将程序的进程号(PID)写入到该文件中,写入后不要关闭文件或解锁(释放文件锁),保证进程一直持有该文件锁;
          • 若是程序获取锁失败,代表程序已经被运行、则退出本次启动。
          • 打开文件之后使用 flock 尝试获取文件锁,调用 flock()时指定了互斥锁标志 LOCK_NB,意味着同时只能有一个进程拥有该锁。
          • 这种机制在一些程序尤其是服务器程序中很常见,服务器程序使用这种方法来保证程序的单例模式运行;在 Linux 系统中/var/run/目录下有很多以.pid 为后缀结尾的文件,这个实际上是为了保证程序以单例模式运行而设计的,作为程序实现单例模式运行所需的特定文件。
          • 如果我们要去实现一个以单例模式运行的程序,譬如一个守护进程,那么也应该将这个特定文件放置于 Linux 系统/var/run/目录下,并且文件的命名方式为 name.pid(name 表示进程名)。

          三、进程间通信简介

          3.1 为什么需要进程间通信

          1. 同一个进程的不同模块(譬如不同的函数)之间进行通信都是很简单的,譬如使用全局变量等
          1. 大多数中小型应用程序不会设计成多进程程序,使用单进程+多线程程序
          1. 复杂、大型的应用程序会考虑设计成多进程程序,比如GUI、服务器应用程序等

          3.2 进程通信来龙去脉

          notion image
           
          AT&T 贝尔实验室,对 UNIX 早期的进程间通信手段进行了系统的改进和扩充,形成了“System V IPC”,通信进程局限在单个计算机内。
          BSD(加州大学伯克利分校的伯克利软件发布中心),形成了基于套接字(Socket,也就是网络)的进程间通信机制。
          • UNIX IPC:管道、FIFO、信号;
          • System V IPC:信号量、消息队列、共享内存;
          • POSIX IPC:信号量、消息队列、共享内存;
          • Socket IPC:基于 Socket 进程间通信

          3.2 怎么实现进程通信

          notion image
          🧭
          选型速查:本机、高频、大数据量优先共享内存 + 信号量(或无锁 ring buffer);简单状态通知用信号;结构化小消息、需要内核保证顺序/原子性用消息队列;跨主机通信必须用 socket;管道/FIFO 适合简单顺序字节流场景。
          方式
          适用场景
          关键限制
          管道 pipe / FIFO
          简单顺序字节流;pipe 限父子/兄弟进程,FIFO 可用于无亲缘关系进程
          半双工,基于内核缓冲区,吞吐和灵活性有限
          信号 signal
          异步事件/状态通知(如 SIGCHLD)
          不能携带有效数据,只能通知
          消息队列
          结构化小消息,需要顺序/原子性保证
          内核维护带类型/优先级的消息链表,发送接收是原子操作,由内核保证顺序和同步;有拷贝开销和消息大小/队列长度限制
          共享内存 + 信号量
          本机高频大数据量传输
          零拷贝、速度最快,但内核不提供同步,需要自己配合信号量/互斥锁做并发控制
          socket
          跨主机网络通信,也支持本机(Unix Domain Socket)
          最通用但开销相对最大

          3.2.1 管道(pipe)

          • 普通 pipe : 单工,只能在父子或兄弟进程间使用
          • 流管道 s_pipe : 半双工,只能在父子或兄弟进程间使用
          • 有名管道 name_pipe(FIFO) :允许在不相关进程间进行通信

          3.2.2 信号(signal)

          用于通知接收进程某个事件已经发生。
          输出结果

          3.2.3 消息队列(message queue)

          克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺陷。
          输出结果

          3.2.4 信号量( semophore )

          • 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源;
          • 主要作为进程间以及同一进程内不同线程之间的同步手段;
          • Linux提供了一组精心设计的信号量接口来对信号量进行操作,它们声明在头文件sys/sem.h中。

          3.2.5 共享内存( shared memory )

          共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
          输出结果:
          🧭
          上面这个例子用的是 MAP_SHARED | MAP_ANONYMOUS,适合 fork() 之后的父子进程共享。它重点演示“多个进程的虚拟地址映射到同一批物理页”这个机制;但它不是 POSIX/System V 共享内存的标准接口示例。无亲缘关系进程要共享,通常要用 POSIX shm:shm_open + ftruncate + mmap,或者 System V shm:shmget + shmat
          POSIX 共享内存标准接口示例:shm_open + mmap
          POSIX 共享内存的核心思路是:先用一个名字创建共享内存对象,得到 fd;再用 ftruncate() 设置大小;最后各个进程分别 mmap() 同一个对象,映射到自己的虚拟地址空间。
          写进程:创建并写入共享内存
          读进程:打开同一个共享内存对象并读取
          关键点:
          • shm_open() 创建的是一个内核维护的共享内存对象,Linux 上通常挂在 tmpfs/shmem 体系下,不是普通磁盘文件。
          • ftruncate() 必须设置对象大小,否则后续访问超过对象大小可能触发 SIGBUS
          • mmap(..., MAP_SHARED, fd, 0) 才是把该对象映射进进程虚拟地址空间。
          • 多个进程可以用同一个 SHM_NAME 打开同一个对象,因此不要求父子关系。
          • POSIX shm 只解决“共享同一批物理页”,不解决同步;并发读写仍要配合 POSIX 信号量、进程间 mutex、futex 或 ring buffer 协议。
          为什么共享内存最快?相比消息队列的短板是什么
          共享内存快的根本原因是零拷贝:内核只需建立一次页表映射,之后各进程直接读写同一块物理内存,不需要像消息队列/管道那样每次通信都经历“用户态→内核缓冲区→用户态”的两次数据拷贝。
          ⚠️
          共享内存本身不提供任何同步机制,内核不保证互斥访问,完全靠用户自己用信号量/互斥锁/自旋锁做同步;一旦同步没做好,就会出现竞态条件、脏读(读到写了一半的数据)、多核下的缓存一致性问题。 消息队列则相反:入队出队由内核托管、是原子操作,内核天然保证顺序和互斥,可靠性和易用性更好,代价是有拷贝开销和大小限制。
          一句话总结:共享内存用“牺牲内核保证的安全性”换取“零拷贝的性能”

          3.2.6 mmap 内存映射:匿名映射、文件映射与注意点

          mmap 的本质是:在进程虚拟地址空间里建立一段 VMA,让这段虚拟地址以后可以映射到某个对象。这个对象可以是匿名内存、普通文件、共享内存对象,也可以是设备物理地址。调用 mmap() 时通常只是建立映射关系,真正的物理页往往等首次访问触发缺页异常时才分配或装入。
          🧠
          一句话锁死:虚拟地址上,mmap 返回的地址通常位于用户态的 mmap 区;物理地址上,没有所谓“mmap 专属物理区”。匿名 mmap 首次访问后映射到普通匿名物理页,文件 mmap 首次访问后映射到文件 page cache 对应的物理页。
          1. mmap 为什么可能比 read/write 高效?
          mmap 把文件、设备物理内存或匿名内存映射到进程虚拟地址空间后,用户态可以像访问普通内存一样访问这段区域。相比 read/write,它的优势主要有两点:
          1. 少一次数据拷贝read() 读文件通常是“磁盘 → 内核 page cache → 用户 buffer”;文件 mmap 建立映射后,用户态直接访问 page cache 对应的物理页,少了 “page cache → 用户 buffer” 这一拷贝。
          1. 减少系统调用次数read/write 每次调用都要陷入内核;mmap 建立映射后,正常读写只是普通内存访问,只有首次访问、换页、权限异常等情况才会通过缺页异常进入内核。
          ⚠️
          易错点:不能说 mmap“没有用户态/内核态切换”。mmap() 建立映射本身就是系统调用,首次访问触发 page fault 也会陷入内核。准确说法是:mmap 减少了重复系统调用和数据拷贝,不是完全没有内核参与。
          2. 匿名 mmap vs 文件 mmap
          维度
          匿名 mmap
          文件 mmap
          典型写法
          MAP_ANONYMOUS,fd 通常为 -1
          传入真实文件 fd 或共享内存对象 fd
          背后对象
          没有普通文件作为后备对象
          普通文件、POSIX shm、memfd、设备文件等
          首次访问
          缺页后分配匿名物理页,通常清零
          缺页后查 page cache;没有则从磁盘或对应对象读入
          修改去向
          修改留在内存中;释放后消失
          MAP_SHARED 修改进入 page cache,后续可写回文件;MAP_PRIVATE 修改走 COW,不改原文件
          典型用途
          大块动态内存、父子进程共享内存、临时 buffer
          大文件随机访问、动态库加载、内存映射数据库、无亲缘进程共享内存
          3. 匿名 mmap 做共享内存的边界
          匿名 mmap 也能做共享内存,但要看进程关系:
          • MAP_SHARED | MAP_ANONYMOUSfork() 前创建,父子进程继承同一段映射,可以共享同一批物理页。
          • 两个无亲缘关系的进程各自调用一次 MAP_SHARED | MAP_ANONYMOUS,不会共享。它们只是各自创建了一块新的匿名共享对象。
          • 无亲缘进程要共享,通常需要一个共同锚点:普通文件、shm_open() 创建的 POSIX shm 对象、memfd_create() 创建的匿名文件 fd,或者 System V shm 对象。
          🧭
          关键判断:共享内存不是必须靠“磁盘文件”,而是必须靠“共同对象”。文件 mmap、POSIX shm、memfd 的共同价值,是让多个进程能引用到同一个内核对象,然后把它映射进各自的虚拟地址空间。
          4. MAP_PRIVATEMAP_SHARED 不要混
          • MAP_PRIVATE:私有映射。文件内容可以作为初始数据源,但写入时触发 COW,修改不会回写原文件。
          • MAP_SHARED:共享映射。多个进程映射同一个对象时,页表可以指向同一批物理页;对文件映射的修改会进入 page cache,之后可写回文件。
          ⚠️
          MAP_SHARED 不等于自动同步。它只保证“映射对象可共享”,不保证多进程读写顺序。并发访问仍然要配合信号量、进程间 mutex、futex、eventfd 或无锁队列协议。
          5. 文件 mmap 的持久化注意点
          文件 mmap 写入后,数据通常先变成 page cache 里的脏页,不代表已经落盘。
          • 想把映射区修改刷回文件:用 msync()
          • 想保证文件元数据和内容一起持久化:通常还要考虑 fsync()
          • munmap() 不等于强持久化保证;它会解除映射,但脏页什么时候真正落盘仍受内核 writeback 和文件系统策略影响。
          一句话:mmap 写文件 ≠ 数据立刻到磁盘。它只是把写入路径换成了“写 page cache”,持久化仍然要显式同步。
          6. mmap 的常见坑
          坑点
          正确理解
          以为 mmap 一定更快
          大文件随机访问、多进程共享 page cache 时很有优势;小文件顺序读未必比 read() 更赚,因为缺页异常也有成本
          以为 mmap 后物理页立刻分配
          多数情况下只是先建 VMA,首次访问才通过缺页异常分配匿名页或装入 page cache 页
          以为匿名 mmap 无法共享
          MAP_SHARED | MAP_ANONYMOUS 可以在 fork 后父子进程间共享;但无亲缘进程需要共同对象
          以为文件 mmap 修改立刻落盘
          MAP_SHARED 写入先进入 page cache,可靠持久化需要 msync() / fsync() 配合
          以为共享内存自带同步
          共享内存只解决“数据放在哪”,不解决“谁什么时候读写”;同步协议必须自己设计
          访问超过文件实际大小
          文件 mmap 越界访问可能触发 SIGBUS,映射前要 ftruncate() 设置足够大小
          7. 面试回答模板
          mmap 是把某个对象映射到进程虚拟地址空间,让用户态像访问内存一样访问它。匿名 mmap 背后没有普通文件,首次访问时缺页分配匿名物理页;文件 mmap 背后有文件或共享内存对象,首次访问时缺页会查 page cache,没有则读入。
          mmap 相比 read/write 的优势是少一次 page cache 到用户 buffer 的拷贝,并减少重复系统调用,但不是完全没有内核态切换,因为 mmap 本身是系统调用,首次访问也可能触发缺页异常。
          共享内存场景下,匿名 mmap 可以用于 fork 后父子进程共享;无亲缘进程一般需要文件、POSIX shm、memfd 或 System V shm 这种共同对象。共享内存速度快,但不提供同步,必须配合信号量、进程间锁或 ring buffer 协议,否则会出现竞态和脏读。

          3.2.7 套接字( socket )

          • 套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信
          notion image
          notion image

          3.3 IPC 高频易错点总结

          🧨
          • 只列 IPC 类型、不给选型理由:面试官常追问“为什么用这个不用别的”,答不出“为什么不用消息队列(有拷贝开销/大小限制)”“为什么不用管道(吞吐不够/半双工限制)”这类对比论证,就等于没答到得分点。 - 漏掉消息队列的核心特点:容易只说“结构化、有类型”,漏掉“内核保证入队出队的原子性和顺序”这个和共享内存做对比时最关键的一句。 - “无用户态-内核态切换”是常见的错误表述:只要访问路径上还会触发系统调用或缺页异常,就不是“无切换”,应该说“减少拷贝次数/减少陷入内核的次数”——共享内存本身建立映射要有一次 shmget/mmap 系统调用,mmap 首次访问缺页也会陷入内核。 - 共享内存“最快”≠“没有代价”:代价是内核完全不管同步,竞态、脏读、多核缓存一致性问题都要自己用信号量/锁解决,回答时要主动提这个短板,而不是只强调速度快。

          引用

          CPU眼里的:进程、线程 | MMU系列 | 空间独立性 - 阿布的视频 - 知乎 https://www.zhihu.com/zvideo/1469706912229429248
          MMU 的视角,重新认识线程和进程,了解“空间独立性”的基本硬件原理和使用场景
          网络服务器更多使用多进程,很少使用多线程
          上一篇
          Linux 文件 IO
          下一篇
          多线程、并发与线程同步

          Comments
          Loading...
          Catalog