MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

进程创建与终止的过程剖析

2024-10-196.6k 阅读

进程创建的过程剖析

进程创建的背景与需求

在现代操作系统中,为了高效地利用系统资源并支持多任务处理,进程的创建是必不可少的操作。当用户启动一个新的应用程序,或者系统为了执行特定任务(如后台服务等)时,都需要创建新的进程。进程作为程序的一次执行实例,拥有自己独立的地址空间、资源(如文件描述符、内存等),它的创建涉及到系统资源的分配与初始化等一系列复杂操作。

例如,当用户双击桌面上的浏览器图标时,操作系统需要为浏览器程序创建一个新的进程,这个进程要能够独立运行浏览器的各种功能,包括加载网页、处理用户输入等,同时与系统中的其他进程相互隔离,避免相互干扰。

进程创建在不同操作系统中的共性基础

虽然不同操作系统(如Windows、Linux、macOS等)在进程创建的具体实现细节上有所不同,但从宏观角度来看,它们都遵循一些基本的步骤和原则。

  1. 资源分配:为新进程分配必要的系统资源。这包括内存空间,用于存储进程的代码、数据以及运行时堆栈等;还可能涉及到分配CPU时间片(虽然在创建时可能只是预留概念,后续调度时才真正分配)、文件描述符(如果进程需要进行文件操作)等。
  2. 进程控制块(PCB)初始化:进程控制块是操作系统用于管理进程的核心数据结构,它记录了进程的各种信息,如进程ID、状态、优先级、资源使用情况等。在进程创建时,需要对PCB进行初始化,填充各种必要的字段。
  3. 程序加载:将程序的可执行代码和相关数据加载到分配的内存空间中。这涉及到从存储设备(如硬盘)读取程序文件,并按照一定的内存布局规则将其放置到合适的内存位置。

Linux系统下进程创建的详细过程

在Linux系统中,进程创建主要通过fork()exec()系列函数来完成。

  1. fork()函数
    • fork()函数用于创建一个新的进程,称为子进程。子进程是父进程的一个副本,它几乎复制了父进程的所有资源,包括内存空间(通过写时复制技术,COW,后续会详细介绍)、文件描述符等。
    • 代码示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    pid_t pid;
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        return 1;
    } else if (pid == 0) {
        // 子进程执行的代码
        printf("This is the child process, pid = %d\n", getpid());
    } else {
        // 父进程执行的代码
        printf("This is the parent process, pid = %d, child pid = %d\n", getpid(), pid);
    }
    return 0;
}
  • 在上述代码中,fork()函数调用后,会返回两次。在父进程中,fork()返回子进程的进程ID(pid);在子进程中,fork()返回0。通过返回值,父、子进程可以执行不同的代码逻辑。
  • 写时复制(Copy - On - Write,COW):Linux采用写时复制技术来提高进程创建的效率。当fork()创建子进程时,子进程并不立即复制父进程的整个内存空间,而是与父进程共享相同的物理内存页面。只有当父进程或子进程试图修改这些共享页面时,才会为修改的页面分配新的物理内存,并将数据复制到新的页面中。这种机制大大减少了进程创建时的内存复制开销,特别是对于内存占用较大的进程。
  1. exec()系列函数
    • fork()创建的子进程通常会紧接着调用exec()系列函数中的一个(如execl()execv()execle()等),以执行一个新的程序。exec()函数会用新的程序替换当前进程的内存空间(代码段、数据段、堆栈等),从而使进程执行不同的功能。
    • execl()为例,其函数原型为int execl(const char *path, const char *arg0, ..., (char *)0);path是要执行的程序的路径,arg0是传递给程序的第一个参数,后续的参数以可变参数的形式给出,最后以(char *)0作为参数列表的结束标志。
    • 代码示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    pid_t pid;
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        return 1;
    } else if (pid == 0) {
        // 子进程执行exec函数
        execl("/bin/ls", "ls", "-l", NULL);
        perror("execl error");
        return 1;
    } else {
        // 父进程等待子进程结束
        wait(NULL);
        printf("Parent process continues\n");
    }
    return 0;
}
  • 在这个例子中,子进程调用execl("/bin/ls", "ls", "-l", NULL);,这会用/bin/ls程序替换子进程原来的内存空间,并执行ls -l命令。如果exec()调用成功,将不会返回;如果调用失败,会通过perror()输出错误信息。

Windows系统下进程创建的详细过程

在Windows系统中,进程创建主要通过CreateProcess()函数来实现。

  1. CreateProcess()函数
    • CreateProcess()函数原型为BOOL CreateProcess(LPCTSTR lpApplicationName, LPTSTR lpCommandLine, LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, BOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, LPCTSTR lpCurrentDirectory, LPSTARTUPINFO lpStartupInfo, LPPROCESS_INFORMATION lpProcessInformation);
    • 参数说明
      • lpApplicationName:指向要执行的应用程序的路径。如果为NULL,则应用程序路径必须在lpCommandLine参数中指定。
      • lpCommandLine:指向命令行字符串,通常包含要执行的程序名以及传递给该程序的参数。
      • lpProcessAttributeslpThreadAttributes:分别用于设置新进程及其主线程的安全属性。如果为NULL,则使用默认的安全描述符。
      • bInheritHandles:指定新进程是否继承调用进程的所有可继承句柄。
      • dwCreationFlags:指定创建进程的各种标志,如是否创建一个新的控制台窗口、进程的优先级等。
      • lpEnvironment:指向新进程的环境块。如果为NULL,新进程将继承调用进程的环境。
      • lpCurrentDirectory:指向新进程的当前目录路径。如果为NULL,新进程将使用与调用进程相同的当前目录。
      • lpStartupInfo:指向STARTUPINFO结构体,用于指定新进程的主窗口如何显示等信息。
      • lpProcessInformation:指向PROCESS_INFORMATION结构体,用于接收新进程及其主线程的句柄和ID等信息。
    • 代码示例:
#include <windows.h>
#include <stdio.h>

int main() {
    STARTUPINFO si;
    PROCESS_INFORMATION pi;

    ZeroMemory(&si, sizeof(si));
    si.cb = sizeof(si);
    ZeroMemory(&pi, sizeof(pi));

    if (!CreateProcess(TEXT("C:\\Windows\\System32\\notepad.exe"), NULL, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) {
        printf("CreateProcess failed (%d).\n", GetLastError());
        return 1;
    }

    // 等待记事本进程结束
    WaitForSingleObject(pi.hProcess, INFINITE);

    // 关闭进程和线程句柄
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

    return 0;
}
  • 在上述代码中,CreateProcess()函数用于启动记事本程序。首先初始化STARTUPINFOPROCESS_INFORMATION结构体,然后调用CreateProcess()函数创建进程。如果创建成功,父进程会等待记事本进程结束,最后关闭相关的句柄。

进程创建过程中的资源管理

  1. 内存资源管理
    • 在进程创建时,内存分配是关键步骤。在Linux系统中,如前所述,fork()时采用写时复制技术,子进程和父进程共享部分内存页面,减少了内存分配的开销。而在Windows系统中,CreateProcess()函数会为新进程分配独立的虚拟地址空间。操作系统的内存管理模块负责管理物理内存与虚拟内存之间的映射关系,确保进程能够正确访问其内存空间。例如,当进程需要更多内存时(如动态分配内存),操作系统会根据内存的使用情况,从空闲内存块中分配相应的内存给进程。
  2. 文件描述符管理
    • 在Linux系统中,进程通过文件描述符来访问文件和其他I/O设备。当fork()创建子进程时,子进程会复制父进程的文件描述符表,这些文件描述符指向相同的打开文件。这意味着父子进程可以共享对文件的访问。而在exec()调用时,默认情况下,除了标准输入、标准输出和标准错误(通常是文件描述符0、1、2)之外,其他文件描述符会被关闭(可以通过设置标志来保留部分文件描述符)。在Windows系统中,进程通过句柄来访问文件等资源。CreateProcess()函数可以设置新进程是否继承父进程的句柄,如果继承,新进程可以访问父进程打开的文件等资源。
  3. CPU资源管理
    • 虽然在进程创建时并没有实际分配CPU时间片,但会为进程分配一个优先级(不同操作系统有不同的优先级分配算法)。例如,在Linux系统中,进程的优先级可以通过nice值来调整,nice值越小,优先级越高。在Windows系统中,进程的优先级类别(如高优先级、正常优先级等)可以在CreateProcess()函数的dwCreationFlags参数中设置。当进程创建后,操作系统的调度器会根据进程的优先级等因素,在合适的时机为进程分配CPU时间片,使其能够真正运行。

进程终止的过程剖析

进程终止的原因

进程终止通常有以下几种原因:

  1. 正常结束:进程完成了其预定的任务,执行到程序的最后一条语句并返回(例如C语言程序中的return语句)。这是进程最常见的正常终止方式。例如,一个简单的计算程序,在完成所有的计算并输出结果后,正常结束进程。
  2. 错误退出:进程在运行过程中遇到了无法处理的错误,例如除零错误、内存访问越界等。在这种情况下,操作系统会检测到错误,并终止该进程,以防止错误进一步扩散影响系统的稳定性。例如,以下C代码会导致除零错误:
#include <stdio.h>

int main() {
    int a = 10;
    int b = 0;
    int result = a / b;
    return 0;
}
  • 当程序执行到int result = a / b;时,会发生除零错误,操作系统会终止该进程,并可能输出错误信息。
  1. 被其他进程终止:一个进程可以通过系统调用(如Linux中的kill()函数,Windows中的TerminateProcess()函数)来请求终止另一个进程。这通常用于管理进程,例如当一个后台服务进程出现异常时,管理员可以通过特定的程序发送终止信号,强制其停止运行。
  2. 父进程终止:在一些操作系统中,如果父进程终止,其所有子进程也会被终止。例如在Linux系统中,当父进程调用exit()函数退出时,其尚未终止的子进程会被init进程(进程ID为1)收养,init进程会负责清理这些子进程的资源。

Linux系统下进程终止的详细过程

  1. 正常终止:在Linux系统中,进程可以通过调用exit()函数来正常终止。exit()函数会执行一系列清理操作,然后将控制权返回给操作系统。
    • 首先,exit()函数会调用所有已注册的终止处理函数(通过atexit()函数注册)。这些函数可以用于释放进程中分配的一些资源,如关闭打开的文件、释放动态分配的内存等。
    • 然后,exit()函数会刷新所有标准I/O流(如stdio库中的缓冲区),确保数据被正确写入文件或输出设备。
    • 最后,exit()函数会向操作系统内核发送一个终止信号,内核会进行一系列的清理工作,包括释放进程占用的内存空间、关闭文件描述符等资源,然后从系统的进程表中删除该进程的进程控制块(PCB)。
    • 代码示例:
#include <stdio.h>
#include <stdlib.h>

void cleanup() {
    printf("Cleanup function called\n");
}

int main() {
    atexit(cleanup);
    printf("Main function is about to exit\n");
    exit(0);
}
  • 在上述代码中,通过atexit(cleanup);注册了一个终止处理函数cleanup()。当exit(0);被调用时,会先执行cleanup()函数,然后进行其他的终止操作。
  1. 异常终止:当进程发生未处理的异常(如段错误、除零错误等)时,Linux内核会向进程发送相应的信号(如SIGSEGV表示段错误,SIGFPE表示浮点运算错误)。默认情况下,这些信号会导致进程终止。进程也可以通过signal()函数或sigaction()函数来捕获这些信号,并进行自定义的处理。如果进程没有捕获信号,内核会按照默认行为终止进程,并可能生成一个核心转储文件(core dump),用于调试分析错误原因。例如,对于段错误:
#include <stdio.h>

int main() {
    int *ptr = NULL;
    *ptr = 10; // 这会导致段错误
    return 0;
}
  • 当程序执行到*ptr = 10;时,由于ptrNULL,会引发段错误,内核会发送SIGSEGV信号,默认情况下进程会终止,并可能生成核心转储文件(如果系统配置允许)。
  1. 被其他进程终止:其他进程可以通过kill()函数向目标进程发送终止信号。kill()函数原型为int kill(pid_t pid, int sig);,其中pid是目标进程的进程ID,sig是要发送的信号。常用的终止信号是SIGTERMSIGKILLSIGTERM是一个正常的终止信号,进程可以捕获并处理这个信号,进行一些清理工作后再终止。而SIGKILL是一个强制终止信号,进程无法捕获,接收到该信号后会立即终止。例如:
#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>

void sig_handler(int signum) {
    printf("Received SIGTERM, cleaning up...\n");
    // 进行清理工作
    exit(0);
}

int main() {
    signal(SIGTERM, sig_handler);
    while (1) {
        printf("Process is running...\n");
        sleep(1);
    }
    return 0;
}
  • 在这个例子中,进程通过signal(SIGTERM, sig_handler);捕获了SIGTERM信号,并在信号处理函数sig_handler()中进行了清理和终止操作。如果使用kill命令向该进程发送SIGTERM信号,进程会执行清理工作后再终止;如果发送SIGKILL信号,进程会立即终止。

Windows系统下进程终止的详细过程

  1. 正常终止:在Windows系统中,进程可以通过调用ExitProcess()函数来正常终止。ExitProcess()函数会导致进程立即终止,所有线程都会停止执行。它不会执行像Linux中atexit()那样的用户定义的清理函数。不过,进程中打开的资源(如文件句柄、内核对象等)会由操作系统自动关闭。例如,在一个C++程序中:
#include <windows.h>
#include <stdio.h>

int main() {
    printf("Main function is about to exit\n");
    ExitProcess(0);
    return 0;
}
  • ExitProcess(0);被调用时,进程会立即终止,操作系统会清理进程占用的资源。
  1. 异常终止:当Windows进程发生未处理的异常(如访问违规、除零错误等)时,操作系统会弹出一个错误对话框,提示用户进程发生错误。用户可以选择关闭进程。操作系统会终止该进程,并清理其占用的资源。例如,以下C++代码会导致访问违规错误:
#include <iostream>

int main() {
    int *ptr = NULL;
    *ptr = 10; // 这会导致访问违规错误
    return 0;
}
  • 当程序执行到*ptr = 10;时,会弹出错误对话框,用户关闭进程后,操作系统会清理进程资源。
  1. 被其他进程终止:一个进程可以通过TerminateProcess()函数来终止另一个进程。TerminateProcess()函数原型为BOOL TerminateProcess(HANDLE hProcess, UINT uExitCode);,其中hProcess是目标进程的句柄,uExitCode是进程的退出码。与Linux的SIGKILL类似,TerminateProcess()是强制终止进程,目标进程没有机会进行清理工作。例如:
#include <windows.h>
#include <stdio.h>

int main() {
    // 假设已经获取到目标进程的句柄hProcess
    HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, targetProcessId);
    if (hProcess != NULL) {
        if (!TerminateProcess(hProcess, 0)) {
            printf("TerminateProcess failed (%d).\n", GetLastError());
        }
        CloseHandle(hProcess);
    }
    return 0;
}
  • 在上述代码中,首先通过OpenProcess()获取目标进程的句柄(假设targetProcessId已经获取到),然后调用TerminateProcess()函数终止目标进程,并关闭句柄。

进程终止过程中的资源回收

  1. 内存资源回收
    • 在Linux系统中,当进程终止时,内核会回收进程占用的虚拟内存空间。对于通过malloc()等函数动态分配的内存,由于进程终止,这些内存块会被标记为空闲,供系统的内存分配器再次使用。对于共享内存区域(如果进程使用了共享内存),内核会根据共享内存的使用情况,决定是否释放该共享内存段。如果没有其他进程在使用该共享内存段,内核会将其释放。在Windows系统中,当进程调用ExitProcess()或被TerminateProcess()终止时,操作系统会回收进程的虚拟地址空间。所有由进程分配的内存,包括堆内存、栈内存等,都会被释放。操作系统的内存管理模块会将这些释放的内存块重新纳入可用内存池,以便其他进程使用。
  2. 文件描述符/句柄回收
    • 在Linux系统中,进程终止时,内核会自动关闭进程打开的所有文件描述符。这意味着与这些文件描述符相关的文件、管道、套接字等资源会被正确关闭。如果进程打开了一些特殊设备文件,如串口设备文件等,设备驱动程序也会进行相应的清理操作。在Windows系统中,当进程终止时,操作系统会关闭进程拥有的所有句柄,包括文件句柄、进程句柄、线程句柄等。这些句柄所代表的资源会被释放,例如关闭打开的文件,释放内核对象等。
  3. 其他资源回收
    • 进程可能还占用其他一些系统资源,如信号量、互斥锁等同步对象。在Linux系统中,当进程终止时,内核会自动释放进程持有的这些同步对象。如果进程是这些同步对象的创建者,内核会根据对象的引用计数等机制,决定是否彻底销毁这些对象。在Windows系统中,当进程终止时,操作系统会清理进程使用的所有内核对象,包括信号量、互斥体、事件等。这些对象所占用的系统资源会被释放,以确保系统资源的有效利用。