Linux 进程必知必会(二)

简介: 只是简单的描述了一下 Linux 基本概念,通过几个例子来说明 Linux 基本应用程序,然后以 Linux 基本内核构造来结尾。那么本篇文章我们就深入理解一下 Linux 内核来理解 Linux 的基本概念之进程和线程。系统调用是操作系统本身的接口,它对于创建进程和线程,内存分配,共享文件和 I/O 来说都很重要。

管道 pipe

Linux 系统中的进程可以通过建立管道 pipe 进行通信

微信图片_20220414204510.png

在两个进程之间,可以建立一个通道,一个进程向这个通道里写入字节流,另一个进程从这个管道中读取字节流。管道是同步的,当进程尝试从空管道读取数据时,该进程会被阻塞,直到有可用数据为止。shell 中的管线 pipelines 就是用管道实现的,当 shell 发现输出

sort <f | head

它会创建两个进程,一个是 sort,一个是 head,sort,会在这两个应用程序之间建立一个管道使得 sort 进程的标准输出作为 head 程序的标准输入。sort 进程产生的输出就不用写到文件中了,如果管道满了系统会停止 sort 以等待 head 读出数据

微信图片_20220414204515.png

管道实际上就是 |,两个应用程序不知道有管道的存在,一切都是由 shell 管理和控制的。

共享内存 shared memory

两个进程之间还可以通过共享内存进行进程间通信,其中两个或者多个进程可以访问公共内存空间。两个进程的共享工作是通过共享内存完成的,一个进程所作的修改可以对另一个进程可见(很像线程间的通信)。

微信图片_20220414204519.png

在使用共享内存前,需要经过一系列的调用流程,流程如下

  • 创建共享内存段或者使用已创建的共享内存段(shmget())
  • 将进程附加到已经创建的内存段中(shmat())
  • 从已连接的共享内存段分离进程(shmdt())
  • 对共享内存段执行控制操作(shmctl())

先入先出队列 FIFO

先入先出队列 FIFO 通常被称为 命名管道(Named Pipes),命名管道的工作方式与常规管道非常相似,但是确实有一些明显的区别。未命名的管道没有备份文件:操作系统负责维护内存中的缓冲区,用来将字节从写入器传输到读取器。一旦写入或者输出终止的话,缓冲区将被回收,传输的数据会丢失。相比之下,命名管道具有支持文件和独特 API ,命名管道在文件系统中作为设备的专用文件存在。当所有的进程通信完成后,命名管道将保留在文件系统中以备后用。命名管道具有严格的 FIFO 行为

微信图片_20220414204522.png

写入的第一个字节是读取的第一个字节,写入的第二个字节是读取的第二个字节,依此类推。

消息队列 Message Queue

一听到消息队列这个名词你可能不知道是什么意思,消息队列是用来描述内核寻址空间内的内部链接列表。可以按几种不同的方式将消息按顺序发送到队列并从队列中检索消息。每个消息队列由 IPC 标识符唯一标识。消息队列有两种模式,一种是严格模式, 严格模式就像是 FIFO 先入先出队列似的,消息顺序发送,顺序读取。还有一种模式是 非严格模式,消息的顺序性不是非常重要。

套接字 Socket

还有一种管理两个进程间通信的是使用 socket,socket 提供端到端的双相通信。一个套接字可以与一个或多个进程关联。就像管道有命令管道和未命名管道一样,套接字也有两种模式,套接字一般用于两个进程之间的网络通信,网络套接字需要来自诸如TCP(传输控制协议)或较低级别UDP(用户数据报协议)等基础协议的支持。

套接字有以下几种分类

  • 顺序包套接字(Sequential Packet Socket):此类套接字为最大长度固定的数据报提供可靠的连接。此连接是双向的并且是顺序的。
  • 数据报套接字(Datagram Socket):数据包套接字支持双向数据流。数据包套接字接受消息的顺序与发送者可能不同。
  • 流式套接字(Stream Socket):流套接字的工作方式类似于电话对话,提供双向可靠的数据流。
  • 原始套接字(Raw Socket):可以使用原始套接字访问基础通信协议。

Linux 中进程管理系统调用

现在关注一下 Linux 系统中与进程管理相关的系统调用。在了解之前你需要先知道一下什么是系统调用。

操作系统为我们屏蔽了硬件和软件的差异,它的最主要功能就是为用户提供一种抽象,隐藏内部实现,让用户只关心在 GUI 图形界面下如何使用即可。操作系统可以分为两种模式

  • 内核态:操作系统内核使用的模式
  • 用户态:用户应用程序所使用的模式

我们常说的上下文切换 指的就是内核态模式和用户态模式的频繁切换。而系统调用指的就是引起内核态和用户态切换的一种方式,系统调用通常在后台静默运行,表示计算机程序向其操作系统内核请求服务。

系统调用指令有很多,下面是一些与进程管理相关的最主要的系统调用

fork

fork 调用用于创建一个与父进程相同的子进程,创建完进程后的子进程拥有和父进程一样的程序计数器、相同的 CPU 寄存器、相同的打开文件。

exec

exec 系统调用用于执行驻留在活动进程中的文件,调用 exec 后,新的可执行文件会替换先前的可执行文件并获得执行。也就是说,调用 exec 后,会将旧文件或程序替换为新文件或执行,然后执行文件或程序。新的执行程序被加载到相同的执行空间中,因此进程的 PID不会修改,因为我们没有创建新进程,只是替换旧进程。但是进程的数据、代码、堆栈都已经被修改。如果当前要被替换的进程包含多个线程,那么所有的线程将被终止,新的进程映像被加载执行。

这里需要解释一下进程映像(Process image) 的概念

什么是进程映像呢?进程映像是执行程序时所需要的可执行文件,通常会包括下面这些东西

  • 代码段(codesegment/textsegment)

又称文本段,用来存放指令,运行代码的一块内存空间

此空间大小在代码运行前就已经确定

内存空间一般属于只读,某些架构的代码也允许可写

在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。

  • 数据段(datasegment)

可读可写

存储初始化的全局变量和初始化的 static 变量

数据段中数据的生存期是随程序持续性(随进程持续性) 随进程持续性:进程创建就存在,进程死亡就消失

  • bss 段(bsssegment):

可读可写

存储未初始化的全局变量和未初始化的 static 变量

bss 段中的数据一般默认为 0

  • Data 段

是可读写的,因为变量的值可以在运行时更改。此段的大小也固定。

  • 栈(stack):

可读可写

存储的是函数或代码中的局部变量(非 static 变量)

栈的生存期随代码块持续性,代码块运行就给你分配空间,代码块结束,就自动回收空间

  • 堆(heap):

可读可写

存储的是程序运行期间动态分配的 malloc/realloc 的空间

堆的生存期随进程持续性,从 malloc/realloc 到 free 一直存在

下面是这些区域的构成图

微信图片_20220414204529.png

exec 系统调用是一些函数的集合,这些函数是

  • execl
  • execle
  • execlp
  • execv
  • execve
  • execvp

下面来看一下 exec 的工作原理

  1. 当前进程映像被替换为新的进程映像
  2. 新的进程映像是你做为 exec 传递的参数
  3. 结束当前正在运行的进程
  4. 新的进程映像有 PID,相同的环境和一些文件描述符(因为未替换进程,只是替换了进程映像)
  5. CPU 状态和虚拟内存受到影响,当前进程映像的虚拟内存映射被新进程映像的虚拟内存代替。

waitpid

等待子进程结束或终止

exit

在许多计算机操作系统上,计算机进程的终止是通过执行 exit 系统调用命令执行的。0 表示进程能够正常结束,其他值表示进程以非正常的行为结束。

其他一些常见的系统调用如下

系统调用指令 描述
pause 挂起信号
nice 改变分时进程的优先级
ptrace 进程跟踪
kill 向进程发送信号
pipe 创建管道
mkfifo 创建 fifo 的特殊文件(命名管道)
sigaction 设置对指定信号的处理方法
msgctl 消息控制操作
semctl 信号量控制

Linux 进程和线程的实现

Linux 进程

Linux 进程就像一座冰山,你看到的只是冰山一角。

在 Linux 内核结构中,进程会被表示为 任务,通过结构体 structure 来创建。不像其他的操作系统会区分进程、轻量级进程和线程,Linux 统一使用任务结构来代表执行上下文。因此,对于每个单线程进程来说,单线程进程将用一个任务结构表示,对于多线程进程来说,将为每一个用户级线程分配一个任务结构。Linux 内核是多线程的,并且内核级线程不与任何用户级线程相关联。

对于每个进程来说,在内存中都会有一个 task_struct 进程描述符与之对应。进程描述符包含了内核管理进程所有有用的信息,包括 调度参数、打开文件描述符等等。进程描述符从进程创建开始就一直存在于内核堆栈中。

Linux 和 Unix 一样,都是通过 PID 来区分不同的进程,内核会将所有进程的任务结构组成为一个双向链表。PID 能够直接被映射称为进程的任务结构所在的地址,从而不需要遍历双向链表直接访问。

我们上面提到了进程描述符,这是一个非常重要的概念,我们上面还提到了进程描述符是位于内存中的,这里我们省略了一句话,那就是进程描述符是存在用户的任务结构中,当进程位于内存并开始运行时,进程描述符才会被调入内存。

进程位于内存被称为 PIM(Process In Memory) ,这是冯诺伊曼体系架构的一种体现,加载到内存中并执行的程序称为进程。简单来说,一个进程就是正在执行的程序。

进程描述符可以归为下面这几类

  • 调度参数(scheduling parameters):进程优先级、最近消耗 CPU 的时间、最近睡眠时间一起决定了下一个需要运行的进程
  • 内存映像(memory image):我们上面说到,进程映像是执行程序时所需要的可执行文件,它由数据和代码组成。
  • 信号(signals):显示哪些信号被捕获、哪些信号被执行
  • 寄存器:当发生内核陷入 (trap) 时,寄存器的内容会被保存下来。
  • 系统调用状态(system call state):当前系统调用的信息,包括参数和结果
  • 文件描述符表(file descriptor table):有关文件描述符的系统被调用时,文件描述符作为索引在文件描述符表中定位相关文件的 i-node 数据结构
  • 统计数据(accounting):记录用户、进程占用系统 CPU 时间表的指针,一些操作系统还保存进程最多占用的 CPU 时间、进程拥有的最大堆栈空间、进程可以消耗的页面数等。
  • 内核堆栈(kernel stack):进程的内核部分可以使用的固定堆栈
  • 其他:当前进程状态、事件等待时间、距离警报的超时时间、PID、父进程的 PID 以及用户标识符等

有了上面这些信息,现在就很容易描述在 Linux 中是如何创建这些进程的了,创建新流程实际上非常简单。为子进程开辟一块新的用户空间的进程描述符,然后从父进程复制大量的内容。为这个子进程分配一个 PID,设置其内存映射,赋予它访问父进程文件的权限,注册并启动

当执行 fork 系统调用时,调用进程会陷入内核并创建一些和任务相关的数据结构,比如内核堆栈(kernel stack)thread_info 结构。

关于 thread_info 结构可以参考

https://docs.huihoo.com/doxygen/linux/kernel/3.7/arch_2avr32_2include_2asm_2thread__info_8h_source.html

这个结构中包含进程描述符,进程描述符位于固定的位置,使得 Linux 系统只需要很小的开销就可以定位到一个运行中进程的数据结构。

进程描述符的主要内容是根据父进程的描述符来填充。Linux 操作系统会寻找一个可用的 PID,并且此 PID 没有被任何进程使用,更新进程标示符使其指向一个新的数据结构即可。为了减少 hash table 的碰撞,进程描述符会形成链表。它还将 task_struct 的字段设置为指向任务数组上相应的上一个/下一个进程。

task_struct :Linux 进程描述符,内部涉及到众多 C++ 源码,我们会在后面进行讲解。

从原则上来说,为子进程开辟内存区域并为子进程分配数据段、堆栈段,并且对父进程的内容进行复制,但是实际上 fork 完成后,子进程和父进程没有共享内存,所以需要复制技术来实现同步,但是复制开销比较大,因此 Linux 操作系统使用了一种 欺骗 方式。即为子进程分配页表,然后新分配的页表指向父进程的页面,同时这些页面是只读的。当进程向这些页面进行写入的时候,会开启保护错误。内核发现写入操作后,会为进程分配一个副本,使得写入时把数据复制到这个副本上,这个副本是共享的,这种方式称为 写入时复制(copy on write),这种方式避免了在同一块内存区域维护两个副本的必要,节省内存空间。

在子进程开始运行后,操作系统会调用 exec 系统调用,内核会进行查找验证可执行文件,把参数和环境变量复制到内核,释放旧的地址空间。

现在新的地址空间需要被创建和填充。如果系统支持映射文件,就像 Unix 系统一样,那么新的页表就会创建,表明内存中没有任何页,除非所使用的页面是堆栈页,其地址空间由磁盘上的可执行文件支持。新进程开始运行时,立刻会收到一个缺页异常(page fault),这会使具有代码的页面加载进入内存。最后,参数和环境变量被复制到新的堆栈中,重置信号,寄存器全部清零。新的命令开始运行。

下面是一个示例,用户输出 ls,shell 会调用 fork 函数复制一个新进程,shell 进程会调用 exec 函数用可执行文件 ls 的内容覆盖它的内存。

微信图片_20220414204541.png

目录
打赏
0
0
0
0
4
分享
相关文章
【Linux进程概念】—— 操作系统中的“生命体”,计算机里的“多线程”
在计算机系统的底层架构中,操作系统肩负着资源管理与任务调度的重任。当我们启动各类应用程序时,其背后复杂的运作机制便悄然展开。程序,作为静态的指令集合,如何在系统中实现动态执行?本文带你一探究竟!
【Linux进程概念】—— 操作系统中的“生命体”,计算机里的“多线程”
深入理解Linux操作系统的进程管理
本文旨在探讨Linux操作系统中的进程管理机制,包括进程的创建、执行、调度和终止等环节。通过对Linux内核中相关模块的分析,揭示其高效的进程管理策略,为开发者提供优化程序性能和资源利用率的参考。
128 1
【Linux】进程IO|系统调用|open|write|文件描述符fd|封装|理解一切皆文件
本文详细介绍了Linux中的进程IO与系统调用,包括 `open`、`write`、`read`和 `close`函数及其用法,解释了文件描述符(fd)的概念,并深入探讨了Linux中的“一切皆文件”思想。这种设计极大地简化了系统编程,使得处理不同类型的IO设备变得更加一致和简单。通过本文的学习,您应该能够更好地理解和应用Linux中的进程IO操作,提高系统编程的效率和能力。
67 34
|
3天前
|
Linux:守护进程(进程组、会话和守护进程)
守护进程在 Linux 系统中扮演着重要角色,通过后台执行关键任务和服务,确保系统的稳定运行。理解进程组和会话的概念,是正确创建和管理守护进程的基础。使用现代的 `systemd` 或传统的 `init.d` 方法,可以有效地管理守护进程,提升系统的可靠性和可维护性。希望本文能帮助读者深入理解并掌握 Linux 守护进程的相关知识。
21 7
|
2天前
|
Linux 进程前台后台切换与作业控制
进程前台/后台切换及作业控制简介: 在 Shell 中,启动的程序默认为前台进程,会占用终端直到执行完毕。例如,执行 `./shella.sh` 时,终端会被占用。为避免不便,可将命令放到后台运行,如 `./shella.sh &`,此时终端命令行立即返回,可继续输入其他命令。 常用作业控制命令: - `fg %1`:将后台作业切换到前台。 - `Ctrl + Z`:暂停前台作业并放到后台。 - `bg %1`:让暂停的后台作业继续执行。 - `kill %1`:终止后台作业。 优先级调整:
23 5
Linux 进程管理基础
Linux 进程是操作系统中运行程序的实例,彼此隔离以确保安全性和稳定性。常用命令查看和管理进程:`ps` 显示当前终端会话相关进程;`ps aux` 和 `ps -ef` 显示所有进程信息;`ps -u username` 查看特定用户进程;`ps -e | grep &lt;进程名&gt;` 查找特定进程;`ps -p &lt;PID&gt;` 查看指定 PID 的进程详情。终止进程可用 `kill &lt;PID&gt;` 或 `pkill &lt;进程名&gt;`,强制终止加 `-9` 选项。
14 3
c++ linux通过实现独立进程之间的通信和传递字符串 demo
的进程间通信机制,适用于父子进程之间的数据传输。希望本文能帮助您更好地理解和应用Linux管道,提升开发效率。 在实际开发中,除了管道,还可以根据具体需求选择消息队列、共享内存、套接字等其他进程间通信方
61 16
Linux:进程间通信(共享内存详细讲解以及小项目使用和相关指令、消息队列、信号量)
通过上述讲解和代码示例,您可以理解和实现Linux系统中的进程间通信机制,包括共享内存、消息队列和信号量。这些机制在实际开发中非常重要,能够提高系统的并发处理能力和数据通信效率。希望本文能为您的学习和开发提供实用的指导和帮助。
163 20
如何在 Linux 系统中查看进程占用的内存?
如何在 Linux 系统中查看进程占用的内存?
535 58
嵌入式Linux系统编程 — 5.3 times、clock函数获取进程时间
在嵌入式Linux系统编程中,`times`和 `clock`函数是获取进程时间的两个重要工具。`times`函数提供了更详细的进程和子进程时间信息,而 `clock`函数则提供了更简单的处理器时间获取方法。根据具体需求选择合适的函数,可以更有效地进行性能分析和资源管理。通过本文的介绍,希望能帮助您更好地理解和使用这两个函数,提高嵌入式系统编程的效率和效果。
133 13
AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等