Linux系统编程——进程
本文系统介绍了Linux进程的基本概念和编程接口。主要内容包括:1)进程定义,作为程序执行的动态实例;2)进程内部结构,包括内存布局和五种状态;3)Linux进程树组织形式;4)常用进程操作函数,如fork创建进程、exec替换进程、wait回收进程等;5)信号处理机制;6)进程标识和优先级管理。文章为理解Linux进程机制和进行系统编程提供了基础,下篇将探讨进程间通信方式。
目录
这篇文章主要介绍一下进程的基本概念和常用的Linux进程相关的函数,下篇文章会介绍进程间通信方式。
引言:为何要学习进程?
在计算机科学的世界中,进程是操作系统的核心概念之一,也是程序从静态代码转变为动态执行的桥梁。无论是编写简单的Shell脚本,还是开发复杂的服务器程序,理解进程的运作机制都是Linux系统编程的基石。
进程——程序的生命形态
简单来说,进程是正在执行的程序的实例。让我们用一个生动的比喻来理解:
想象你有一本烹饪书(程序),书中有制作披萨的完整步骤。当你按照这本烹饪书开始制作披萨时,整个制作过程就是一个进程。这本书可以被多人同时使用(多个进程),每个人做的披萨可能处于不同阶段(进程的不同状态),这就是进程的本质。
进程与程序的关键区别
|
特性 |
程序 |
进程 |
|---|---|---|
|
存在形式 |
静态代码文件 |
动态执行实体 |
|
存储位置 |
磁盘 |
内存 |
|
生命周期 |
永久存在 |
创建、运行、终止 |
|
资源占用 |
不占用系统资源 |
占用CPU、内存等资源 |
进程的诞生:从程序到执行
在Linux中,当我们执行一个程序时,操作系统会执行以下魔法般的转换:
# 当我们输入这个命令时
$ ./my_program
# 操作系统实际上做了这些事情:
# 1. 找到my_program的可执行文件
# 2. 创建一个新的进程描述符(task_struct)
# 3. 为进程分配内存空间
# 4. 将程序代码加载到内存
# 5. 设置程序计数器,开始执行
进程的内部构造
每个进程在Linux内核中都由一个task_struct结构体表示,它包含了进程的所有信息:
进程的关键组成部分
-
进程标识符(PID)
每个进程唯一的身份证号,可以通过getpid()系统调用获取。 -
内存布局
高地址 +-----------------+ | 栈区(stack) | ← 局部变量、函数调用信息 +-----------------+ | ↓ | 动态增长 | ↑ | +-----------------+ | 堆区(heap) | ← 动态分配的内存 +-----------------+ | BSS段(未初始化数据) | +-----------------+ | 数据段(data) | ← 全局变量、静态变量 +-----------------+ | 代码段(text) | ← 程序指令 +-----------------+ 低地址 -
进程状态:Linux进程的五种基本状态
-
运行(Running):正在CPU上执行
-
可中断睡眠(Interruptible Sleep):等待资源,可被信号唤醒
-
不可中断睡眠(Uninterruptible Sleep):等待硬件资源,不可打断
-
停止(Stopped):被信号暂停执行
-
僵尸(Zombie):已终止但未被父进程回收
-
Linux进程的组织方式——Linux进程树
Linux采用一种优雅的进程组织方式——进程树:
init/systemd (PID 1)
├── bash (PID 1000)
│ ├── vim (PID 1001)
│ └── gcc (PID 1002)
│ └── cc1 (PID 1003)
├── sshd (PID 200)
└── nginx (PID 201)
每个进程都有父进程(除了init),形成了清晰的层级关系。这种设计使得进程管理变得简单而强大。
Linux进程编程的常用函数
1.进程创建函数fork
// 创建子进程
pid_t fork(void);
创建一个与当前父进程几乎完全相同的子进程。成功时,在父进程会返回大于0的子进程PID,在子进程会返回0;失败时,在父进程会直接返回-1;
创建的子进程会继承拷贝父进程的资源,拥有继续向下执行的相同的代码段、数据段、堆栈段、父进程的环境变量和打开的文件描述符等资源,并且父子进程资源相互独立。
创建的子进程无法直接复制父进程中原有的所有子线程,只会拷贝一份主进程创建子进程的当前线程,并且由于会复制父进程资源,因此在父进程存在很多子线程时进行fork,可能会造成父进程的子线程死锁,资源泄漏等未定义行为,非常危险。
一般通过判断fork函数的返回值来区分父进程和子进程,从而在不同的进程中执行不同的代码逻辑,子进程常用exec系列函数进行新程序替换。下面是一段父进程创建5个子进程代码:
# 父进程创建5个子线程
pid_t pid[5];
for(int i=0;i<5;i++){
pit_t subpid = fork();
if(subpid==-1)
printf("创建子进程失败\n");
else if(subpid==0){
// 返回值为0,说明是这里是子进程,为避免子进程执行for循环迭代创建子进程,需要退出for循环
break;
}
else{
// subpid>0,说明创建子进程成功,并且子进程pid为返回值
printf("创建子进程成功\n");
pid[i]=subpid;
}
}
2.进程替换函数exec
exec系列函数:一般用于在子进程中加载和执行一个新的程序,替换当前子进程的代码段、数据段和堆栈段为新程序的内容执行,成功时被替换无返回值,失败时返回-1。包括execl、execle、execlp、execv、execve、execvp等函数,区别在于传入参数的形式和环境变量的处理方式不同。eg: "ls"或"/sur/bin/ls", "ls". "-a", NULL
execl系列函数:
l:表示参数以可变参数形式传入,并且每个参数都是单独传入,第一个参数是程序路径或文件名,后续参数是传递给新程序的参数列表,后续参数其中第一个必须是执行程序名或文件名,最后以NULL结尾。
# 以可变参数传入新程序路径path和参数列表arg,arg0必须是执行程序名,参数列表以NULL结尾。
int execl(const char *path, const char *arg0, ..., NULL);
# 类似execl函数,但会在PATH环境变量指定的目录中搜索可执行文件file
int execlp(const char *file, const char *arg0, ..., NULL);
# 类似execl函数,但可以传入自定义环境变量列表envp
int execle(const char *path, const char *arg0, ..., NULL, char * const envp[]);
execv系列函数:
v:表示参数以数组形式传入,第一个参数是程序路径或文件名,第二个参数是一个字符串数组,包含传递给新程序的参数列表,数组第一个参数时程序路径或文件名,数组最后一个元素必须是NULL。eg: "ls"或"/sur/bin/ls", char* argv[] = {"ls", "-a", NULL}
# 以数组形式传入新程序路径path和参数列表argv,argv[0]必须是执行程序名,参数列表以NULL结尾
int execv(const char *path, const char * argv[]);
# 类似execv函数,但会在PATH环境变量指定的目录中搜索可执行文件file
int execvp(const char *file, const char * argv[]);
# 类似execv函数,但可以传入自定义环境变量列表envp
int execve(const char *path, const char * argv[], char * const envp[]);
p:表示会继承当前父进程的PATH环境变量,在PATH环境变量指定的所有目录中搜索同名可执行文件,此时可以只使用命令文件名即可,如:ls、cd等。
e:表示可以传入一个自定义的环境变量列表,而不是继承当前进程的环境变量,此时必须使用完整的相对路径或绝对路径指定可执行文件位置,如:/usr/bin/ls
3.进程状态保存和恢复函数
不屏蔽外部信号方式
int setjmp(jmp_buf environment);
保存当前进程的堆栈环境到environment变量中,第一次成功返回0,以后返回由longjmp函数传入的非0值表示错误,一般用于非信号处理函数时的异常处理。
void longjmp(jmp_buf environment, int value);
恢复之前保存的堆栈环境到environment变量中以使进程回滚,并使setjmp函数返回value值,value必须为非0值。
屏蔽外部型信号方式
int sigsetjmp(sigjmp_buf environment, int savesigs);
保存当前进程的堆栈环境并在savesigs为非0值时保存信号掩码,为0时类似于setjump只保存堆栈环境,第一次成功返回0,以后返回由siglongjmp函数传入的非0值表示错误,一般用于信号处理函数中的异常处理。
void siglongjmp(sigjmp_buf environment, int value);
类似于longjmp函数,在sigsetjump中设置保存信号屏蔽字时还会恢复之前保存的信号掩码到environment变量中,value设置sigsetjump的返回值。
使用方式举例:
# 创建进程环境保存缓冲区
sigjmp_buf sigbuf;
# 进程在运行到某个会出错的关键位置前,先进行保存进程环境状态
int ret = sigsetjmp(sigbuf, 1); # 采用1非0值表示禁止重入
# 判断堆栈错误异常处理结果
if (ret == 0) {
# 执行某些关键逻辑
......
# 进程运行出现错误时,回滚到最近保存进程状态位置sigsetjmp进行恢复,并继续执行
siglongjmp(sigbuf, error); # error传递错误信号int值
}
else {
# 执行出错回到sigsetjmp返回的错误ret的处理逻辑
}
4.进程退出函数
进程退出方式:进程正常退出包括:main函数返回、exit调用、_exit调用或接收终止信号(如SIGTERM)结束。进程异常退出包括:abort调用、assert失败或接收致命信号(如SIGSEGV)异常终止。进程无论以哪种方式退出,内核都会回收进程占用资源,但是用户空间资源需要用户注册on_exit/atexit函数在exit正常退出时回收,并将进程状态设置为终止状态,等待父进程调用wait系列函数获取子进程退出状态并释放进程表项。
# 以异常方式直接结束进程,不调用自定义清理函数atexit或on_exit
void abort(void);
# 若测试的条件不成立则终止进程,不调用自定义清理函数atexit或on_exit
void assert(int expression);
# 正常结束进程,不调用自定义清理函数atexit或on_exit
void _exit(int status);
# 结束进程执行,自动调用自定义清理函数atexit或on_exit
void exit(int status);
# 自定义清理函数,需要传入一个void(void)类型函数指针,在程序exit结束前自动调用作为清理函数
int atexit(void (*func)(void));
# 自定义清理函数,类似于atexit
int on_exit(void (*function)(int, void*), void* arg);
5.进程等待回收函数
用于父进程等待回收子进程,避免出现子进程退出后未及时回收的僵尸进程状态,消耗系统资源。
# 阻塞等待任一子进程结束,获取子进程退出状态,成功返回子进程PID,失败返回-1
pid_t wait(int *status);
# 阻塞或非阻塞等待指定子进程结束,获取子进程终止或暂停状态,成功返回子进程PID,失败返回-1
pid_t waitpid(pid_t pid, int *status, int options);
pid: 传入参数,指定要等待的子进程PID,-1: 等待任一子进程结束,等同于wait函数;0: 等待与当前进程属于同一进程组的任一子进程结束;>0: 等待指定PID的子进程结束。
status: 传出参数,用于存储子进程的退出状态信息,可以通过宏来获取子进程状态。
- WIFEXITED(status)/WEXITSTATUS(status): WIFEXITED宏非0时子进程正常退出,然后使用WEXITSTATUS宏用于获取子进程的退出码exit()。
- WIFSIGNALED(status)/WTERMSIG(status): WIFSIGNALED宏非0时子进程被信号终止,然后使用WTERMSIG宏用于获取终止子进程的信号编号。
- WIFSTOPPED(status)/WSTOPSIG(status): WIFSTOPPED宏非0时子进程被信号暂停,然后使用WSTOPSIG宏用于获取暂停子进程的信号编号。
options: 传入参数,用于控制父进程等待子进程的行为,默认是0表示阻塞等待子进程运行结束,包括以下几种采用|运算符取并操作;WNOHANG: 非阻塞方式等待子进程结束,如果没有子进程结束则立即返回0,不使用时默认是父进程阻塞等待子进程方式;WUNTRACED: 当子进程被信号暂停时也返回其状态,而不仅仅是终止状态;WCONTINUED: 当暂停的子进程继续执行时也返回其状态。
僵尸进程:当子进程结束后,系统已经回收该子进程资源并且父进程存活,其进程表项和状态仍然保留在系统中,直到父进程调用wait或waitpid函数获取子进程的退出状态并释放进程表项,否则子进程会一直处于僵尸状态,占用系统资源。在具有大量子进程产生的情况下,为避免主进程盲目阻塞等待子进程结束并保证系统不会长期存留僵尸进程,一般有两种解决方案:
1.主进程定期非阻塞方式WNOHANG循环waitpid回收当前所有已终止的子进程。
# 周期性地非阻塞方式回收当前已终止的所有子进程以防止主进程运行过程中产生僵尸进程,并保证主进程可以继续运行
int status; // 子进程状态
int pid; // 回收子进程PID
while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
std::cout << "父进程周期性回收子进程:" << pid << " 退出状态" << WEXITSTATUS(status) << std::endl;
2.注册SIGCHLD信号处理函数中循环调用waitpid回收当前所有已终止的子进程。
# 信号标志原子变量,在signal的信号处理函数中使用防止重入,volatile防止编译器优化
volatile sig_atomic_t sig_flag = 0;
# 子进程退出的SIGCHLD信号的处理函数
void (*sigback)(int) = [](int signal) {
switch (signal) {
case SIGCHLD:
// 在旧版signal方式需要采取这种方式防止重入,sigaction方式自动屏蔽自身信号防止重入
if (sig_flag) return; // 出现多个相同信号重入情况时,直接返回
sig_flag = 1; // 设置信号处理中标志,防止重入
// 循环非阻塞等待回收当前所有已终止的子进程,防止僵尸进程
int status; // 子进程状态
int pid; // 回收子进程PID
while ((pid = waitpid(-1, &status, WNOHANG) > 0))
std::cout << "信号SIGCHLD:" << signal << " 回收子进程" << pid << " 退出状态" << WEXITSTATUS(status) << std::endl;
break;
default:
break;
}
};
# 注册信号和处理函数
# 1.采用旧版signal方法注册信号处理函数
signal(SIGINT, sigback);
# 2.采用新版sigaction方法注册信号处理函数,sigaction方法自动屏蔽自身信号防止重入,更加安全和灵活
struct sigaction act;
act.sa_handler = sigback; # 设置信号处理函数
act.sa_flags = SA_RESTART; # 设置信号处理标志位
sigemptyset(&act.sa_mask); # 初始化信号屏蔽字为空
sigaction(SIGINT, &act, NULL); # 注册信号和其处理函数
6.信号处理函数
包括signal函数和sigaction函数,用于注册和处理进程接收到的各种信号,并在相应信号发生时自动调用注册处理函数执行,前者不安全,后者默认屏蔽自身信号防止重入会更加安全和灵活。常见信号包括:SIGINT(ctrl+c中断信号)、SIGCHLD(子进程终止信号)、SIGALRM(超时信号, 通过超时函数alarm调用发出)、SIGTERM(终止信号)、SIGSEGV(段错误信号, 访问非法内存地址时发出)等。
typedef void (*sighandler_t)(int); # 定义信号处理函数指针类型
# 注册信号处理函数handler,用于处理接收到的signum信号,成功返回之前的信号处理函数指针,失败返回SIG_ERR
sighandler_t signal(int signum, sighandler_t handler);
# 注册信号处理函数act,用于处理接收到的sig信号,oldact表示原处理行为,可选NULL,成功返回0,失败返回-1
int sigaction(int sig, const struct sigaction *act, struct sigaction *oldact);
struct sigaction {
void (*sa_handler)(int);# 信号处理函数(同signal的handler)
void (*sa_sigaction)(int, siginfo_t *, void *); # 扩展信号处理函数,需设置 SA_SIGINFO 标志位使用该函数处理信号。
sigset_t sa_mask; # 信号屏蔽字(处理时屏蔽的信号避免中断)
int sa_flags; # 标志位(控制处理行为)
};
sa_mask:信号屏蔽字(处理时屏蔽的信号避免中断),通常使用sigemptyset(&act->sa_mask)清空,sigaddset(&act->sa_mask, sig)添加屏蔽信号sig。
sa_flags:标志位(控制处理行为),SA_RESTART:自动重启被信号中断的系统调用(如 read、sleep)、SA_NODEFER:处理信号时不屏蔽自身(默认会屏蔽,避免重入)、SA_SIGINFO:使用扩展处理函数(sa_sigaction,而非 sa_handler)。
信号处理函数:
// 信号处理函数,std::function<void(int)>无法直接强制转化为void(*)(int),即使包装器对象满足转换条件
void (*sigback)(int) = [](int signal) {
switch (signal) {
// ctrl+c中断信号
case SIGINT:
std::cout << "中断信号SIGINT: " << signal << "发出!" << std::endl;
// 父进程恢复堆栈状态回滚
siglongjmp(sigbuf, signal);
break;
// 子进程终止信号
case SIGCHLD:
// 在旧版signal方式需要采取这种方式防止重入,sigaction方式自动屏蔽自身信号防止重入
if (sig_flag) return; // 出现多个相同信号重入情况时,直接返回
sig_flag = 1; // 设置信号处理中标志,防止重入
// 循环非阻塞等待回收当前所有已终止的子进程,防止僵尸进程
int status; // 子进程状态
int pid; // 回收子进程PID
while ((pid = waitpid(-1, &status, WNOHANG) > 0))
std::cout << "信号SIGCHLD:" << signal << " 回收子进程" << pid << " 退出状态" << WEXITSTATUS(status) << std::endl;
break;
default:
break;
}
};
使用信号函数绑定信号和信号处理函数
# 1.采用旧版signal方法注册信号处理函数
signal(SIGINT, sigback);
# 2.采用新版sigaction方法注册信号处理函数,sigaction方法自动屏蔽自身信号防止重入,更加安全和灵活
struct sigaction act;
act.sa_handler = sigback; # 设置信号处理函数
act.sa_flags = SA_RESTART; # 设置信号处理标志位
sigemptyset(&act.sa_mask); # 初始化信号屏蔽字为空
sigaction(SIGINT, &act, NULL); # 注册信号和其处理函数
7.会话、进程(组)号相关函数
会话:一个或多个进程组的集合,共享相同的会话ID(SID)即会话首进程的PID,一个会话最多可以有一个或者没有控制终端,并且只有会话首进程可以打开控制终端,会话首进程即第一个创建会话的进程,也必定是进程组长。会话首进程退出时,会话内的所有其他进程会收到SIGHUP信号,并且会断开控制终端。默认情况下,SIGHUP信号在默认情况下会导致进程终止,但进程可以选择处理或忽略这个信号。
控制终端:字符设备文件,用于处理用户输入和输出的与用户交互接口,在设备文件/dev目录下。包括:物理终端(串行终端ttyS0、ttyS1等)、虚拟控制台(tty1-tty63)、伪终端(软件模拟ptmx、pts)、控制台设备(系统控制台console、当前虚拟控制台tty0)、当前进程控制终端tty等。多个控制终端意味着可以有多个用户通过会话与系统进行交互。
进程组:进程的集合,具有相同的进程组ID(PGID)。它不是简单的逻辑分组,而是Unix作业控制的基础。进程组组长是第一个创建进程组的进程,其PID就是进程组的PGID。进程组生命周期是进程组存在直到最后一个进程离开(终止或加入其他进程组)。向进程组发送信号会影响组内所有进程。
进程:进程是程序执行的实例,拥有独立的内存空间、文件描述符、信号处理器等资源。
控制终端、会话、进程组、进程
控制终端 (Controlling Terminal)
│
└── 会话 (Session) ←─────────────────── 作业控制单位
│
├── 前台进程组 (Foreground Process Group) ←── 接收终端输入/信号
│ ├── 进程1 (bash) - 进程组组长
│ ├── 进程2 (vim)
│ └── 进程3 (gcc)
│
├── 后台进程组1 (Background Process Group 1)
│ ├── 进程4 (httpd)
│ └── 进程5 (mysqld)
│
└── 后台进程组2 (Background Process Group 2)
├── 进程6 (cron)
└── 进程7 (logd)
①会话创建和获取函数
# 获取指定进程的会话SID
pid_t getsid(pid_t pid);
如果pid为0时,返回调用进程自身的会话SID,如果pid大于0时,返回指定进程pid的会话ID,执行成功会返回指定进程所在的会话SID,执行失败返回-1。
# 创建新会话
pid_t setsid(void);
执行成功会返回新会话SID,即调用该函数创建新会话的进程PID,并且该进程为新会话首进程和新进程组组长,同时会断开与该进程与之前控制终端的关联,即该新会话此时处于没有控制终端状态,但是如果该会话首进程打开终端设备文件,系统可能会为其设置控制终端,执行失败返回-1。
②进程组创建和获取函数
# 获取指定pid进程的进程组PID,成功返回进程组PID,失败返回-1。
pid_t getpgid(pid_t pid);
# 获取当前进程的进程组PID,成功返回进程组PID,失败返回-1。
pid_t getpgrp(void);
在默认情况下,对于初始进程,进程组PID与进程PID相同为组长。
# 将当前进程PID为其PGID,即为组长,成功返回0,失败返回-1。
int setpgrp(void);
# 设置指定pid进程的进程组PID为pgid
int setpgid(pid_t pid,pid_t pgid);
给指定进程pid设置其所属进程组,当pgid为0时,设置其PID作为PGID创建新组并为组长,成功返回0,失败返回-1,在权限不够时(父进程设置子进程或root用户)或进程不存活时会失败。
③获取进程号PID函数
# 获取当前进程PID,由操作系统唯一标识分配,无法修改
pid_t getpid(void);
# 获取当前进程的父进程PID,由操作系统唯一标识分配,无法修改
pid_t getppid(void);
守护进程:Linux系统中在后台运行的特殊进程,它不依赖于任何终端,通常随着系统启动而启动,在系统关闭时终止。守护进程的名称通常以"d"结尾,比如sshd、httpd、mysqld等。
核心特征
-
无控制终端控制:不与任何终端关联,不会接收终端信号(SIGHUP),无法与用户交互。
-
后台运行:不占用控制台
-
独立会话:有自己的会话和进程组
-
生命周期长:通常在系统运行期间一直存在,随系统运行结束而终止
创建流程
(1)第一次fork是为了创建子进程在主进程直接结束后变成孤儿进程,直接受到init进程管理,脱离控制终端控制。
(2)然后执行setsid函数创建新会话,创建该进程为新会话首进程并创建新进程组,此时的问题是该进程为会话首进程,存在可能打开终端设备文件时,被系统设置为控制终端,从而在控制终端被系统关闭时,接收到SIGHUP信号而被意外关闭。
(3)第二次fork是为了创建子进程并退出新会话首进程的父进程,这样保证在该子进程不是会话首进程,从而无法打开终端设备文件,不会被系统设置控制终端。
(4)设置守护进程需要应用的进程属性。

8.进程执行优先级相关函数
①获取进程执行优先级函数
# 获取指定进程的优先级值
int getpriority(int which,int who);
which指定获取类型包括PRIO_PROCESS(进程)、PRIO_PGRP(进程组)、PRIO_USER(用户),who指定具体的进程PID、进程组PGID或用户UID,成功返回优先级值,失败返回-1。值越低优先级越高。
②设置进程执行优先级函数
# 设置指定进程的优先级值
int setpriority(int which,int who,int prio);
which指定获取类型包括PRIO_PROCESS(进程)、PRIO_PGRP(进程组)、PRIO_USER(用户)。who指定具体的进程PID、进程组PGID或用户UID。prio指定新的优先级值,成功返回0,失败返回-1。值越低优先级越高,设置优先级需要一定的权限,没有权限时会执行失败。
# 更改当前进程优先级,成功返回新的优先级值,失败返回-1。
int nice(int value);
value值可以为负数与当前进程优先级进行相加更改,一般取值范围为-20到19。降低值以提供优先级值需要超级用户权限,普通用户只能增加优先级值从而降低进程优先级
更多推荐



所有评论(0)