文章目录
- 进程概念
- task_struct
- 进程创建
- fork
- vfork
- 写时拷贝
- 进程状态
- 僵尸进程
- 孤儿进程
- 守护进程
- 进程地址空间
- 是什么
- 为什么
- 怎么做
进程概念
进程是一个程序的执行实例或者是担当系统资源分配的实体。当一个程序运行时,被从硬盘加载到内存中,操作系统为每个程序运行定义了描述信息,通过这个描述信息来控制和管理程序的运行,因此对于操作系统来说,pcb就是进程,而Linux的pcb是task_struct。所有运行在系统里的进程都以task_struct链表的形式存在内核里。
进程 = 内核关于进程的数据结构 + 当前进程的代码 + 数据
task_struct
task_struct
是Linux内核中用于表示进程(或线程)的数据结构。在Linux内核源代码中,task_struct
结构体定义了进程的各种属性和状态信息,包括进程的标识、调度信息、内存管理信息等。
-
进程标识符(PID): 每个进程在系统中都有一个唯一的PID,用于区分和标识不同的进程。
-
进程状态: 进程可能处于运行态、就绪态、睡眠态、停止态等不同的状态,
task_struct
中会记录当前进程的状态。 -
进程调度信息: 包括进程的优先级、调度策略、调度器相关的信息等。
-
进程描述符: 描述进程的各种属性和信息,如进程名称、用户标识、组标识等。
-
进程的父子关系: 指向父进程和子进程的指针,用于建立进程之间的父子关系链表。
-
地址空间信息: 包括进程的虚拟地址空间、页表信息等。
-
文件描述符表: 记录进程打开的文件以及文件的状态信息。
-
信号处理器: 记录进程注册的信号处理函数以及当前信号的状态。
-
调度器相关信息: 与进程调度相关的一些附加信息,如调度实体、调度策略等。
-
内存管理信息: 包括进程在内存中的分配情况、内存页的状态等。
进程创建
fork
fork()
函数用于创建进程。当一个进程(父进程)调用fork()
函数时,它创建了一个新的进程(子进程)。这个新的子进程几乎是父进程的完整副本:它继承了父进程的地址空间、进程环境、打开的文件描述符等资源。不过,父进程和子进程有不同的进程ID,并且它们的内存是分开的。
当调用fork()
时,操作系统做了以下几步:
- 分配新的进程ID(PID)给子进程。
- 复制父进程的地址空间到子进程,包括代码段、数据段和堆栈。
- 复制父进程的进程控制块(PCB),包括文件描述符、信号处理方式等。
- 为子进程分配独立的内存空间。
fork()
函数的返回值:
- 对于父进程:
fork()
返回新创建子进程的进程ID(PID>0)。 - 对于子进程:
fork()
返回0。 - 错误情况:如果出现错误,
fork()
返回-1,并设置errno
以指示错误原因。
注意事项:
fork()
之后,父子进程各自独立执行,但是由于地址空间的复制,它们会从fork()
调用的下一条指令开始执行。- 父子进程间共享文件描述符,如果不希望共享,需要在
fork()
之后,exec()
之前调用close()
。 fork()
可能会因为系统资源不足而失败。- 创建进程后,父进程通常会调用
wait()
或waitpid()
等待子进程结束,避免僵尸进程的产生。
vfork
vfork()
用于创建一个新进程,但是与fork()
有所不同。vfork()
是"虚拟fork"的缩写,它与fork()
类似,但是设计用于创建新进程的方式不同。
vfork()
与fork()
的区别主要在于 fork()
操作可能会导致内存的复制。而在vfork()
中,子进程共享父进程的地址空间。子进程会暂时阻塞父进程的运行,直到调用exec()
或exit()
为止。由于子进程与父进程共享内存空间,所以对内存的修改会影响父进程,因此子进程通常需要立即调用exec()
来替换自己的内存空间,或者直接调用exit()
来结束自己的执行。
注意:
-
vfork()
适用于需要在子进程中立即调用exec()
来执行新程序的场景,因为子进程共享父进程的内存空间,所以对于子进程而言,任何内存修改都会影响到父进程,这样可以减少内存的复制和性能开销。 -
一般来说,如果没有立即调用
exec()
或exit()
,而是在子进程中执行其他操作,可能会导致父进程和子进程的行为变得不可预测,因为它们共享相同的内存空间。
写时拷贝
写时拷贝(Copy-On-Write,简称COW)是一种用于资源管理的优化策略,广泛应用于操作系统中,尤其是在进程创建(如fork()
系统调用)时。在没有写时拷贝技术之前,fork()
调用会直接复制父进程的整个地址空间到子进程,这个过程不仅耗时,还会消耗大量的系统资源。写时拷贝技术的引入,显著优化了这一过程。
在使用写时拷贝技术时,fork()
调用并不立即复制父进程的物理内存页面到子进程。相反,操作系统使父进程和子进程共享同一物理内存页面,页面的权限被设置为只读。如果父进程或子进程尝试写入这些共享页面,操作系统会捕捉到这一写操作尝试,并仅为执行写操作的进程复制被写的页面,在必要时才会进行数据复制。这种延迟复制的策略极大地提高了系统的效率和性能。
优点:
- 减少不必要的数据复制
- 提高效率和性能
- 优化内存使用
- 简化编程模型
进程状态
在Linux系统中,进程的状态是通过进程控制块中的状态字段来表示的。主要包括以下几种:
- 运行(R, Running or Runnable): 这个状态意味着进程要么正在CPU上运行,要么在等待队列中准备运行。换句话说,这些进程是准备执行的。
- 中断睡眠(S, Interruptible Sleep): 进程因等待某个条件而睡眠,如等待输入/输出操作完成。在这个状态下,进程会睡眠直到等待的事件发生,但可以被信号(signal)唤醒。
- 不可中断睡眠(D, Uninterruptible Sleep): 这也是一种睡眠状态,但进程不能通过信号唤醒。这通常发生在进程等待某些硬件操作完成时,比如磁盘I/O。
- 暂停(T, Stopped or Traced): 进程已经被停止,通常是因为接收到了停止信号。这个状态的进程可以通过接收SIGCONT信号重新进入运行状态。
- 僵尸(Z, Zombie): 进程已经结束,但其父进程还没有通过调用wait()来回收其资源。在这个状态下,进程释放了除进程控制块之外的所有资源,等待父进程读取其退出状态。
- 死亡(X, Dead): 这个状态比较少见,表示进程已经被完全销毁,但在某些情况下,可能会在进程列表中短暂看到。
僵尸进程
僵尸进程产生的主要原因是父进程没有及时处理子进程的退出状态。当一个子进程终止时,内核会向父进程发送一个信号,告知子进程已经退出,并且等待父进程调用 wait()
或 waitpid()
来获取子进程的退出状态。如果父进程没有处理这个退出状态,子进程就会变成僵尸进程。
僵尸进程可能产生的一些原因包括:
-
父进程没有正确地调用
wait()
或waitpid()
函数来等待子进程的退出状态。 父进程可能忙于处理其他任务,或者没有正确处理子进程退出的情况,导致子进程变成僵尸进程。 -
父进程被阻塞,无法处理子进程的退出状态。 如果父进程正在执行一些阻塞操作,例如等待 I/O 完成,那么它可能无法及时处理子进程的退出状态。
僵尸进程的存在可能会对系统造成一些危害:
-
资源浪费: 僵尸进程在系统进程表中占用资源,虽然不再执行任何代码,但仍然占用一些系统资源,例如进程标识符(PID)等。
-
影响系统稳定性: 如果系统中存在大量的僵尸进程,可能会消耗系统的资源,并且可能影响系统的性能和稳定性。
-
影响进程管理: 如果系统中存在过多的僵尸进程,会使得进程管理变得困难,特别是在需要监控和管理系统进程的情况下。
为了避免僵尸进程的产生和危害,父进程应该在子进程终止后及时调用 wait()
或 waitpid()
函数来等待子进程的退出状态,并确保对子进程进行适当的处理。这样可以及时释放子进程所占用的系统资源,并保持系统的稳定性和可靠性。
孤儿进程
孤儿进程是指其父进程先于它结束,由 init 进程(PID 为 1)接管的进程。在 Unix/Linux 系统中,每个进程都有一个父进程,当父进程退出或终止时,内核会将孤儿进程的父进程设置为 init 进程。
孤儿进程的产生可以由以下几种情况引起:
- 当一个父进程创建了一个子进程后,如果父进程先于子进程退出,并且子进程还在运行,那么子进程就会成为孤儿进程。
- 如果一个进程是由 init 进程直接启动的,而 init 进程不会退出,那么这个进程本身就是一个孤儿进程。
孤儿进程并不会被立即回收,而是会继续运行,直到它自己退出或被系统进程管理器接管。当孤儿进程退出时,它的资源会被释放,但是其进程号(PID)会一直保留在系统中,直到父进程调用 wait()
或 waitpid()
来获取退出状态。
孤儿进程的存在并不会对系统造成太大的影响,但是如果大量孤儿进程堆积在系统中,会占用系统资源。
守护进程
守护进程(Daemon Process)是在后台运行的一种特殊类型的进程,通常在系统引导时启动,并且在系统关闭时终止。它们在操作系统启动时启动,通常不与任何控制终端关联。
守护进程通常用于执行系统服务或后台任务,它们独立于用户会话并在后台默默地执行工作。守护进程的特点包括:
-
没有控制终端: 守护进程通常不会与任何控制终端(如终端或控制台)关联,因此它们不接收或发送任何与终端相关的输入或输出。
-
独立于用户会话: 守护进程不受用户登录或注销的影响,它们在系统启动时启动,在系统关闭时终止。
-
后台运行: 守护进程在后台默默地执行任务,通常不会产生交互式的用户界面。
-
执行系统服务或后台任务: 守护进程通常用于执行系统服务,如网络服务、日志服务、定时任务等,或者执行后台任务,如定期备份、监控等。
-
通常以超级用户权限运行: 一些守护进程需要特殊的权限才能执行其任务,因此它们通常以超级用户(root)的身份运行。
编写守护进程的关键是将进程脱离控制终端,并且使之成为后台进程。通常涉及到以下几个步骤:
- 调用
fork()
创建子进程,然后父进程退出,使得子进程成为孤儿进程。 - 调用
setsid()
函数创建新的会话,并使进程成为会话组的首领,脱离控制终端。 - 改变当前工作目录,防止守护进程占用某个挂载点而导致其无法卸载。
- 关闭不需要的文件描述符,防止其在后续操作中产生问题。
- 执行守护进程的核心任务。
进程地址空间
是什么
Linux进程地址空间是操作系统为每个正在运行的进程分配的虚拟内存空间。它是进程所能访问的全部内存区域的抽象表示,包含了进程运行时所需的代码、数据、堆、栈等内容。
Linux进程地址空间通常被划分为以下几个主要部分:
-
代码段(Text Segment): 代码段存放着可执行程序的指令代码。在Linux中,这部分内存通常是只读的,以防止程序意外修改自身的指令内容。
-
数据段(Data Segment): 数据段存放着已初始化的全局变量和静态变量的内存空间。这些变量在程序开始时就已经分配了内存空间,并且可以在程序的整个生命周期中使用。
-
堆(Heap): 堆是动态分配内存的区域,程序可以在运行时通过调用
malloc()
、calloc()
或realloc()
等函数从堆中分配内存。堆的大小在程序运行过程中是动态变化的,取决于程序的动态内存分配和释放操作。 -
栈(Stack): 栈用于存储函数调用期间的局部变量、函数参数、返回地址等信息。每次函数调用时,都会在栈上分配一段内存空间,函数返回时则释放这段空间。栈的大小在程序启动时就确定了,并且通常比堆小得多。
-
内存映射区域(Memory-mapped Regions): 这部分内存用于存储程序加载的动态链接库、共享库、内存映射文件等内容。它们被映射到进程的地址空间中,使得程序可以直接访问这些内容。
-
其他: 除了以上主要的部分外,Linux进程地址空间还可能包括一些其他区域,例如进程环境变量、命令行参数、进程控制块等。
Linux进程地址空间的管理由操作系统内核负责,它通过虚拟内存管理来管理和分配进程的地址空间,以及实现内存保护和地址映射等功能。每个进程都拥有自己独立的地址空间,使得多个进程之间的内存访问彼此隔离,提高了系统的安全性和稳定性。
为什么
进程地址空间在操作系统中起着至关重要的作用,没有它会导致诸多问题:
-
内存隔离: 进程地址空间使得每个进程拥有独立的内存空间,进程之间的内存互相隔离。这种隔离确保了进程的安全性和稳定性,防止了进程之间的相互干扰和不受控制的内存访问。
-
内存保护: 进程地址空间允许操作系统实现内存保护机制,通过设置页面权限和地址空间布局,防止进程对其他进程的内存区域进行非法访问或修改。这有助于防止恶意软件攻击和进程崩溃。
-
资源管理: 进程地址空间使得操作系统可以更好地管理系统资源,包括内存、CPU 时间和文件描述符等。操作系统可以根据进程的需要动态调整地址空间的大小和布局,以优化系统性能和资源利用率。
-
动态内存分配: 进程地址空间允许程序在运行时动态地分配和释放内存,从而实现灵活的内存管理。程序可以根据需要动态调整堆和栈的大小,以适应不同的运行环境和内存需求。
-
共享内存和通信: 进程地址空间为进程间通信提供了基础,允许进程通过共享内存等机制进行数据交换和通信。这种通信方式在多进程和多线程编程中非常常见,可以提高程序的效率和并发性。
怎么做
当源代码经过编译、链接等处理后,生成了 ELF(Executable and Linkable Format)格式的可执行文件。当用户执行一个 ELF 格式的可执行文件时,操作系统会创建一个新的进程,并为其分配独立的进程地址空间。进程地址空间是操作系统为进程分配的虚拟内存空间,用于存放程序的代码、数据、堆、栈等内容。
ELF 文件是一种标准的可执行文件格式,在 Unix、Linux 和类 Unix 系统中广泛使用。它包含了程序的代码、数据、符号表、重定位表等信息。
ELF 格式可执行文件在进程地址空间中的运行过程如下:
- 当用户执行一个 ELF 可执行文件时,操作系统会加载该文件到内存中,然后将其内容映射到新创建的进程地址空间中。
- ELF 文件的代码段被映射到进程的代码段,数据段被映射到进程的数据段,全局变量和静态变量等被放置在数据段中。
- 堆和栈被初始化并分配给进程,堆用于动态内存分配,栈用于函数调用和局部变量存储。
- 进程的地址空间中还可能包括共享库、内存映射文件等内容,它们也被映射到进程的地址空间中。
由上就可以知道操作系统运行程序的步骤大概如下:
-
加载 ELF 文件:
- 操作系统的程序加载器(如 Linux 上的
ld-linux.so
)首先解析 ELF 文件头,确定程序入口点(entry point),代码(.text)段、数据(.data 和 .bss)段等必要信息。 - 加载器将 ELF 文件中的各个段映射到进程的虚拟地址空间中。代码段通常是只读的,数据段可以是可写的。
- 操作系统的程序加载器(如 Linux 上的
-
地址空间映射:
- ELF 文件中定义的虚拟地址在此步骤中被映射到进程的虚拟地址空间。这涉及到将程序的代码和数据加载到内存中的适当位置。
- 对于动态链接的应用程序,加载器还负责解析和加载所有需要的共享库(.so 文件),并将它们映射到进程的地址空间中。
-
重定位:
- ELF 文件可能需要重定位,以便调整代码和数据中的某些引用,使它们指向正确的地址。对于动态链接的应用程序,这也包括解析符号引用,将它们绑定到实际的内存地址。
-
初始化:
- 在开始执行程序之前,操作系统或运行时环境可能需要执行一些初始化操作,例如初始化运行库、设置堆、处理环境变量和命令行参数等。
-
执行:
- 一切准备就绪后,控制权被转移给程序的入口点(通常是一个名为
main
的函数)。此时,程序开始执行其代码。 - 程序运行过程中,可以进行函数调用、内存分配、输入输出操作等。
- 一切准备就绪后,控制权被转移给程序的入口点(通常是一个名为
-
终止:
- 程序执行完毕后(例如
main
函数返回),或者调用退出(如exit
)函数,程序开始终止过程。 - 在终止过程中,操作系统负责清理资源,关闭打开的文件描述符,释放内存等,最终结束进程。
- 程序执行完毕后(例如
整个过程是由操作系统的内核、程序加载器、运行时库共同协作完成的。这确保了程序能够被正确地加载、执行,并在执行完毕后,相关资源能够被妥善地释放。