admin管理员组文章数量:1037775
【Linux】深入理解进程管理与高效运用
在Linux世界中,进程是在其中扮演着不同角色的演员,有的在默默守护系统安全,有的在进行复杂的运算,有的在解决用户的请求。进程管理则是在幕后管理各个进程的导演。
基础概念
1.1概述
进程是正在运行的程序实例,在Linux内核中,进程被称为任务,例如内核线程与用户线程等。
进程是一个程序的执行实例,也就是正在执行的程序。在操作系统的眼里,进程是一个担当分配系统资源(CPU时间、内存)的实体。操作系统用一个进程控制块的数据结构(进程属性的集合)来描述进程信息,在Linux操作系统下的PCB是task_struct,程序运行时它会被装载到RAM里储存进程信息。task_struct包含标识符、状态。优先级。程序计数器、内存指针、上下文数据。I/O状态数据、记账信息等内容。
可以通过/proc系统文件夹来查看进程信息。要获取PID为多少的进程信息,需要在命令行中输入ls /proc/PID。大多数进程信息可以使用top和ps这些用户级工具获取。
进程有多种状态,例如运行状态(R)、睡眠状态(S)、磁盘休眠级(D)、停止状态(T)、追踪状态(t)、死亡状态(X)、僵尸状态(Z)等。
僵尸进程时处于僵死状态的进程,产生原因时子进程先于父进程退出,父进程没有读取到子进程退出的返回代码,这时候子进程为了保存退出原因,因此进入僵死态不会释放所有资源。僵尸进程会以终止状态保存子啊进程表中,并一直等待父进程读取其退出状态码。会造成资源泄露,内存资源的浪费。
孤儿进程是相对于僵尸进程而言,父进程先于子进程退出,子进程会进入后台运行,成为孤儿进程,孤儿进程随后会被1号init进程领养并回收,也就是将其父进程变为innit进程。
1.2进程四要素
Linux 进程有四个关键要素,分别为有一段程序供其执行、有进程专用的系统堆栈空间、在内核有 task_struct 数据结构、有独立的存储空间拥有专有的用户空间。
不同的要素组合会对应不同类型的进程。如果缺少第四条要素,即有一段程序供其执行、有进程专用的系统堆栈空间、在内核有 task_struct 数据结构但没有独立的存储空间拥有专有的用户空间,这种情况被称为线程。在 Linux 中,线程本质上仍是进程,线程是轻量级的进程,也有 PCB。无论是创建进程的 fork,还是创建线程的 pthread_create,底层都是调用内核函数 clone。若复制(深拷贝,但有 “COW” 优化)父进程地址空间,则为进程;若共享(浅拷贝)父进程地址空间,则为线程。
Linux 内核不区分进程和线程,只在上层应用区分。线程操作函数 pthread_* 是库函数,而非系统调用。线程具有提高并发性、开销小、共享数据方便等优点,但也存在库函数不稳定、调试困难、信号支持不好等缺点。线程间共享资源包括文件描述符表、各信号处理方式、当前工作目录、用户 ID 和组 ID、内存地址空间中的.txt、.data、.bss、.heap、. 共享库等;非共享资源包括线程 id、处理器现场和栈指针(内核栈)、线程栈(用户空间栈)、errno 变量、信号屏蔽字、调度优先级等。
如果完全没有用户空间,则为内核线程。内核线程是直接由内核本身启动的进程,实际上是将内核函数委托给独立的进程,与系统中其他进程 “并行” 执行,内核线程经常被称为内核 “守护进程”。它们主要用于执行如周期性地将修改的内存页与页来源块设备同步、如果内存页很少使用则写入交换区、管理延时动作、实现文件系统的事务日志等任务。内核线程有两种主要类型:一种是线程启动后一直等待,直至内核请求线程执行某一特定操作;另一种是线程启动后按周期性间隔运行,检测特定资源的使用,在用量超出或低于预置的限制时采取行动。
内核线程由内核自身生成,其特点在于它们在 CPU 的管态执行,而不是用户态;只可以访问虚拟地址空间的内核部分(高于 TASK_SIZE 的所有地址),但不能访问用户空间。task_struct 进程描述符中对于普通用户进程来说,mm 指向虚拟地址空间的用户空间部分,而对于内核线程,mm 为 NULL。active_mm 主要用于优化,由于内核线程不与任何特定的用户层进程相关,内核并不需要倒换虚拟地址空间的用户层部分,保留旧设置即可。
假如内核线程之后运行的进程与之前是同一个,内核并不需要修改用户空间地址表,TLB 中信息仍然有效;只有在内核线程之后执行的进程与此前用户层进程不同时,才需要切换,并清除对应 TLB 数据。内核线程可以通过将一个函数传递给 kernel_thread,该函数接下来负责帮助内核调用 daemonize 已转换为守护进程,或者使用辅助函数 kthread_create 创建一个新的内核线程,最初线程是停止的,需要使用 wake_up_process 启动它,或使用 kthread_run 创建新线程后立即唤醒它。
如果共享用户空间映射,则为用户线程。在 Linux 系统中,进程都具有四个要素,缺一条就不成其为进程。如果只具备前三个要素,即有一段程序供其执行、有进程专用的系统堆栈空间、有自己独立的 task_struct 数据结构但共享用户空间,则称为用户线程,也可以看作是个轻量级进程。
进程的实现
明白了进程的基本概念之后,我们来看一看Linux是怎么实现进程的。按照标准的操作系统理论,进程是资源分配的单位,线程是程序执行的单位,内核里用进程控制块(PCB Process Control Block)来管理进程,用线程控制块(TCB Thread Control Block)来管理线程。那么Linux是按照这个逻辑来实现进程的吗?
2.1 基本原理
Linux内核并不是按照标准的操作系统理论来实现进程的,在内核里找不到典型的进程控制块和线程控制块。内核里只有一个task_struct结构体,初学内核的人会很疑惑这是代表进程还是代表线程呢。之所以会这样,是由于历史原因造成的。Linux最开始的时候是不支持多线程的,也可以认为此时一个进程只能有一个线程就是主线程,因此线程就是进程,进程就是线程。所以最初的时候,task_struct既代表进程又代表线程,因为进程和线程没有区别。但是后来Linux也要支持多线程了,多线程的实现方法可以在内核实现,也可以在用户空间实现,也可以同时实现,Linux选择的是在内核实现。
为了最大限度地利用已有的代码,尽量不对代码做大的改动,Linux选择的方法是:task_struct既是线程又是进程的代理。注意这句话,task_struct既是线程又是进程的代理(不是进程本身)。Linux并没有设计单独的进程结构体,而是用task_struct作为进程的代理,这是因为进程是资源分配的单位,线程是程序执行的单位,同一个进程的所有线程共享相同的资源,因此我们让同一个进程下的所有线程(task_struct)都指向相同的资源不就可以了嘛。线程在执行的时候会通过task_struct里面的指针访问资源,同一个进程下的线程自然就会访问到相同的资源,而且这么做还有很大的灵活性。task_struct既是线程又是进程的代理(不是进程本身)。
2.2进程描述符
Linux 内核用 task_struct 结构表示进程,此结构包含表示进程所需的所有数据。其中 state 变量表示任务状态,它可以取多种值,如 TASK_RUNNING 表示进程是可执行的或正在运行;TASK_INTERRUPTIBLE 表示进程正在睡眠但可被信号唤醒;TASK_UNINTERRUPTIBLE 表示进程在睡眠且不可被信号唤醒等。通过修改 state 的值,操作系统可以改变进程的状态,实现任务的管理和调度,从而实现多任务的并发执行。
flags 定义了很多指示符,它用来标识任务的不同状态,如运行、等待、停止等。另外,还有 task_struct->signal,用来管理任务的信号处理。comm 字段存储可执行程序名称,可在 setup_new_exec () 函数进行初始化,也可调用 [gs] et_task_comm () 函数获取,获取时需要用 task_lock () 锁定。tasks 字段提供链接列表能力,Linux 内核将所有进程的进程描述符 task_struct 数据结构链成一个单链表 (task_struct->tasks),这个链表贯穿了整个操作系统的任务管理和调度过程。
mm 和 active_mm 字段表示进程地址空间,对于普通用户进程来说,mm 指向虚拟地址空间的用户空间部分,而对于内核线程,mm 为 NULL。active_mm 主要用于优化,假如内核线程之后运行的进程与之前是同一个,内核并不需要修改用户空间地址表,TLB 中信息仍然有效;只有在内核线程之后执行的进程与此前用户层进程不同时,才需要切换,并清除对应 TLB 数据。
thread_struct thread 结构标识进程存储状态,包含了进程的上下文信息,如程序计数器、寄存器值等。任务的上下文切换是实现任务调度和多任务并发执行的基础,而 thread_struct 提供了保存和恢复任务上下文所需的关键数据。
⑴进程结构体
当我们明白了task_struct既是线程又是进程的代理之后,再来理解task_struct就容易多了。task_struct的字段由两部分组成,一部分是线程相关的,一部分是进程相关的,线程相关的一般是直接内嵌其它数据,进程相关的一般是用指针指向其它数据。线程代表的是执行流,所以task_struct的线程相关部分是和执行有关的,进程代表的是资源分配,所以task_struct的进程相关部分是和资源有关的。我们可以想一下和执行有关的都有哪些,和资源有关的都哪些?可以很轻松地想到,和执行有关的肯定是进程调度相关的数据啊(进程调度虽然叫进程调度,但实际上调度的是线程)。和资源相关的,最重要的首先肯定是虚拟内存啊,其次是文件系统。
下面我们来看一下task_struct的定义:
linux-src/include/linux/sched.h
代码语言:javascript代码运行次数:0运行复制struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
struct thread_info thread_info;
#endif
unsigned int __state;
void *stack;
unsigned int flags;
int on_cpu;
unsigned int cpu;
int recent_used_cpu;
int wake_cpu;
int on_rq;
int prio;
int static_prio;
int normal_prio;
unsigned int rt_priority;
const struct sched_class *sched_class;
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
unsigned int policy;
int nr_cpus_allowed;
cpumask_t cpus_mask;
struct sched_info sched_info;
struct list_head tasks;
struct mm_struct *mm;
struct mm_struct *active_mm;
struct vmacache vmacache;
int exit_state;
int exit_code;
int exit_signal;
pid_t pid;
pid_t tgid;
struct task_struct __rcu *real_parent;
struct task_struct __rcu *parent;
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
unsigned long nvcsw;
unsigned long nivcsw;
u64 start_time;
u64 start_boottime;
unsigned long min_flt;
unsigned long maj_flt;
char comm[TASK_COMM_LEN];
struct fs_struct *fs;
struct files_struct *files;
struct signal_struct *signal;
struct sighand_struct __rcu *sighand;
sigset_t blocked;
sigset_t real_blocked;
sigset_t saved_sigmask;
struct sigpending pending;
struct thread_struct thread;
};
与进程相关的,首先最重要的是虚拟内存空间信息mm、active_mm,这两个都是指针,对于用户线程来说两个指针的值永远都是相同的,同一个进程的所有线程都指向相同的mm,这个值就表明了同一个进程的线程都在同一个用户空间。
其次比较重要的是文件管理相关的两个字段fs和files,也都是指针,fs代表的是文件系统挂载相关的,这个不仅是同进程的所有线程都相同,而且整个系统默认的值都一样,除非使用了mount 命名空间,files代表的是打开的文件资源,这个是同进程的所有线程都相同。然后我们再来看一下信号相关的,信号有的数据是进程全局的,有的是线程私有的,信号的处理是进程全局的,所以signal、sighand两个字段都是指针,同进程的所有线程都指向同一个结构体,信号掩码是线程私有的,所以blocked直接是内嵌数据。
进程相关的数据基本就这些,下面我们来看一下线程相关的数据。首先是进程的运行退出状态,有几个字段,__state、on_cpu、cpu、exit_state、exit_code、exit_signal。然后是和线程调度相关的几个字段,有和优先级相关的rt_priority、static_prio、normal_prio、prio,有和调度信息统计相关的两个结构体,se、sched_info。
⑵进程标识符
task_struct里面有两个重要的字段pid、tgid。我们在用户空间的时候也有pid、tid,那么用户空间的pid是不是就是内核的pid呢,那tgid又是啥呢。很多初学内核的人会认为用户空间的pid就是内核的pid,刚开始我也是这么认为的,给我的内核学习带来了很大的困扰。实际上用户空间的tid是内核空间pid,用户空间的pid是内核空间的tgid,内核空间的tgid是内核里主线程的pid。
为什么会这样呢?主要还是前面讲的问题,task_struct既是线程又是进程的代理,没有单独的进程结构体。当进程创建时,也就是进程的第一个线程创建时,会为task_struct分配一个pid,就是主线程的tid,然后进程的pid也就是字段tgid会被赋值为主线程的tid。此后再创建的线程都会继承父线程的tgid,所以在每个线程中都能直接获取进程的pid。
Linux里面虽然没有进程结构体,但是所有tgid相同、虚拟内存等资源相同的线程构成一个虚拟的进程结构体。创建进程的第一个线程(task_struct)就是同时在创建进程,其对应的mm_struct、files_struct、signal_struct等资源都会被创建出来。创建进程的第二个线程那就是纯粹地创建线程了。
2.3进程的状态
进程的状态在Linux中是如何表示的呢?task_struct中有两个字段用来表示进程的状态,__state和exit_state,前者是总体状态,后者是进程在死亡时的两个子状态。
我们来看一下代码中的定义:linux-src/include/linux/sched.h
代码语言:javascript代码运行次数:0运行复制/* Used in tsk->state: */
#define TASK_RUNNING 0x0000
#define TASK_INTERRUPTIBLE 0x0001
#define TASK_UNINTERRUPTIBLE 0x0002
#define __TASK_STOPPED 0x0004
#define __TASK_TRACED 0x0008
/* Used in tsk->exit_state: */
#define EXIT_DEAD 0x0010
#define EXIT_ZOMBIE 0x0020
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_PARKED 0x0040
#define TASK_DEAD 0x0080
#define TASK_WAKEKILL 0x0100
#define TASK_WAKING 0x0200
#define TASK_NOLOAD 0x0400
#define TASK_NEW 0x0800
其中TASK_RUNNING代表的是Runnable和Running状态。在Linux中不是用flag直接区分Runnable和Running状态的,它们都用TASK_RUNNING表示,区分它们的方法是进程是否在运行队列的当前进程字段上。Blocked状态有两种表示,TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE,它们的区别是前者在睡眠时能被信号唤醒,后者不能被信号唤醒。表示死亡的状态是TASK_DEAD,它有两个子状态EXIT_ZOMBIE、EXIT_DEAD。
进程的生命周期
了解了进程的基本概念,明白了进程在Linux中的实现,下面我们再来看一看进程的生命周期。进程的生命周期和进程的五态转化有关联,但是又不完全相同。
进程从无到有要经历新建的状态,在Linux上创建进程和加载程序是两个不同的步骤。刚创建出来的进程和父进程几乎是一模一样,要想执行新的程序还得经历装载的过程。程序装载完成之后就会进入就绪、执行、阻塞的循环了,这个是进程调度里面的内容。实际上程序在main函数之前还经历了两个过程,分别是so的加载和程序本身的初始化。进程执行到最后总会经历死亡,无论是主动退出还是意外死亡。下面我们就详细分析一下进程的这几个生命周期。
3.1进程创建
在 Linux 内核中,有三种系统调用可用于创建新进程:fork、vfork 和 clone。
fork () 函数是 Linux 最常见的创建进程的方式,在调用 fork () 函数后,系统会为新进程分配资源,例如内存空间,然后复制父进程的全部资源到子进程中,因此新进程和父进程几乎完全一样。此函数的特点是子进程是父进程的复制品,并且父子进程会在不同的内存空间运行。fork () 函数采用定时复制技术,写时复制是其优化策略,在父子进程没有写入操作时,数据是共享的,当任意一方试图写入,便以写时拷贝的方式各自拥有一份副本。
vfork () 函数是为了解决 fork () 函数内存资源需求问题而出现的。它在创建新进程时并不会复制父进程的资源,而是子进程与父进程共享内存空间,只有在子进程结束或调用 exec 系列函数之后,父进程才会恢复运行。此函数的特点是资源共享,节省内存。但由 vfork 创建出来的子进程会导致父进程挂起,除非子进程 exit 或者 execve 才会唤起父进程,且子进程不应该使用 return 返回调用者,或者使用 exit () 退出,可以使用 _exit () 函数来退出。
clone () 函数是 Linux 中更加灵活的创建进程方式。它可以指定共享哪些资源,可以实现和 fork ()、vfork () 相同的功能,也可以用于创建轻量级的线程。此函数的特点是创建进程方式灵活,可定制性强。clone () 函数主要供 pthread 库创建线程,参数多使用复杂,fork 是其简化函数。通过设置不同的 flags 参数,可以控制子进程和父进程共享的资源,例如共享内存空间、文件系统信息、文件描述符等。如果复制(深拷贝,但有 “COW” 优化)父进程地址空间,则为进程;若共享(浅拷贝)父进程地址空间,则为线程。
我们先来看一下fork的接口定义:
代码语言:javascript代码运行次数:0运行复制#include <unistd.h>
pid_t fork(void);
fork系统调用不接受任何参数,返回值是个pid。第一次接触fork的人难免会有疑惑,fork是怎么创建进程的呢?答案是fork会返回两次,在父进程中返回一次,在子进程中返回一次,在父进程中返回的是子进程的pid,在子进程中返回的是0,如果创建进程失败则返回-1。估计很多人还是难以理解这是什么意思。下面我们再举个例子用代码来演示一下。
代码语言:javascript代码运行次数:0运行复制#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main(int argc, char *argv[]){
pid_t pid = fork();
if (pid == -1) {
printf("fork error, exit\n");
exit(-1);
} else if (pid == 0) {
printf("I am child process, pid:%d\n", getpid());
pause();
} else {
printf("I am parent process, pid:%d, my child is pid:%d\n", getpid(), pid);
waitpid(pid, NULL, 0);
}
}
从这个例子中,我们可以看到fork的用法,当fork返回值为0时代表是子进程,我们可以在这里做一些要在子进程中做的事。
那么fork系统调用是怎么实现的呢?让我们来看一下代码:linux-src/kernel/fork.c
代码语言:javascript代码运行次数:0运行复制SYSCALL_DEFINE0(fork){
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
pid_t kernel_clone(struct kernel_clone_args *args){
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
int trace = 0;
pid_t nr;
/*
* For legacy clone() calls, CLONE_PIDFD uses the parent_tid argument
* to return the pidfd. Hence, CLONE_PIDFD and CLONE_PARENT_SETTID are
* mutually exclusive. With clone3() CLONE_PIDFD has grown a separate
* field in struct clone_args and it still doesn't make sense to have
* them both point at the same memory location. Performing this check
* here has the advantage that we don't need to have a separate helper
* to check for legacy clone().
*/
if ((args->flags & CLONE_PIDFD) &&
(args->flags & CLONE_PARENT_SETTID) &&
(args->pidfd == args->parent_tid))
return -EINVAL;
/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
add_latent_entropy();
if (IS_ERR(p))
return PTR_ERR(p);
/*
* Do this prior waking up the new thread - the thread pointer
* might get invalid after that point, if the thread exits quickly.
*/
trace_sched_process_fork(current, p);
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, args->parent_tid);
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
wake_up_new_task(p);
/* forking complete and child started to run, tell ptracer */
if (unlikely(trace))
ptrace_event_pid(trace, pid);
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);
return nr;
}
内核本身有fork的系统调用,但是glibc的fork API是用clone系统调用来实现的,我们知道这一点就行了,实际上它们最后调用的代码还是一样的,所以我们还用fork系统调用来讲解,没有影响。可以看到fork系统调用什么也没做,直接调用的kernel_clone函数,kernel_clone以前叫做do_fork,现在改名了。
kernel_clone的逻辑也很简单,就是做了两件事,一是copy_process复制task_struct,二是wake_up_new_task唤醒新进程。copy_process会根据flag来决定新的task_struct是自己创建新的mm_struct、files_struct等结构体,还是和父线程共享这些结构体,由于我们这里是创建进程,所以这些结构体都会创建新的。系统调用执行完成后就会返回,返回值是子进程的pid。而子进程被wake_up之后会被调度执行,它返回到用户空间时返回值是0。
3.2 进程的装载
新的进程刚刚创建之后执行的还是旧的程序,想要执行新的程序的话还得使用系统调用execve。execve会把当前程序替换为新的程序。下面我们先来看一下execve的接口:
代码语言:javascript代码运行次数:0运行复制#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);
第一个参数是要执行的程序的路径,可以是相对路径也可以是绝对路径。第二个参数是程序的参数列表,我们在命令行执行命令时后面跟的参数会被放到这里。第三个参数是环境变量列表,在命令行执行程序时bash会被自己的环境变量放到这里传给子进程。
除此之外,libc还提供了几个API可以用来执行新的进程,它们的功能是一样的,只是参数有所差异,这些API的实现还是使用的系统调用execve。
代码语言:javascript代码运行次数:0运行复制#include <unistd.h>
extern char **environ;
int execl(const char *pathname, const char *arg, ... /*, (char *) NULL */);
int execlp(const char *file, const char *arg, ... /*, (char *) NULL */);
int execle(const char *pathname, const char *arg, ... /*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
3.3 进程的加载
这一节要讲的是解释器的加载过程,这个过程也被叫做动态链接。加载器的实现是在Glibc里面。我们这里就是大概介绍一下加载器的逻辑,具体的细节大家可以去看参考文献中的书籍。
ELF格式的可执行程序和共享库里面有一个段叫做.dynamic,这个段里面会记录程序所依赖的所有so。so里面的.dynamic段也会记录自己所依赖的所有so。解释器会通过深度优先或者广度优先的方法找到一个程序所依赖的所有so,然后加载它们。
加载一个so会首先解析它的ELF头部信息,然后通过mmap为它的数据段代码段分配内存,并设置不同的读写执行权限。最后会对so进行重定位,重定位包括全局数据重定位和函数重定位。
3.4 进程的初始化
进程完成加载之后不是直接就执行main函数的,而是会执行ELF文件的入口函数。这个入口函数叫做_start,_start完成一些基本的设置之后会调用__libc_start_main。__libc_start_main完成一些初始化之后才会调用main函数。你会发现,我们上学的时候讲的是程序执行的时候会首先执行main函数,实际上在main函数执行之前还发生了很多很复杂的事情,只不过这些事情系统都帮我们悄悄地做了,如果我们想要研究透彻的话还是很麻烦的。
3.5 进程的运行
程序在运行的时候会不停地经历就绪、运行、阻塞的过程。
3.6 进程的死亡
进程执行到最后总会死亡的,进程死亡的原因可以分为两大类,正常死亡和非正常死亡。
正常死亡的情况有:
- 1.main函数返回。
- 2.进程调用了exit、_exit、_Exit 等函数。
- 3.进程的所有线程都调用了pthread_exit。
- 4.主线程调用了pthread_exit,其他线程都从线程函数返回。
非正常死亡的情况有:
- 1.进程访问非法内存而收到信号SIGSEGV。
- 2.库程序发现异常情况给进程发送信号SIGABRT。
- 3.在终端上输入Ctrl+C给进程发送信号SIGINT。
- 4.通过kill命令给进程发送信号SIGTERM。
- 5.通过kill -9命令给进程发送信号SIGKILL。
- 6.进程收到其它一些会导致死亡的信号。
main函数返回本质上也是调用的exit,因为main函数外还有一层函数__libc_start_main,它会在main函数返回后调用exit。exit的实现调用的是系统调用exit_group,pthread_exit的实现调用的是系统调用exit。
这里就体现出了API和系统调用的不同。进程由于信号原因而死的,其死亡方法也是内核在信号处理中调用了系统调用exit_group,只不过是直接调用的函数,没有走系统调用的流程。系统调用exit的作用是杀死线程,系统调用exit_group的作用是杀死当前线程,并给同进程的所有其它线程发SIGKILL信号,这会导致所有的线程都死亡,从而整个进程也就死了。
每个线程死亡的时候都会释放对进程资源的引用,最后一个线程死亡的时候,资源的引用计数会变成0,从而会去释放这个资源。总结一下就是进程的第一个线程创建的时候会去创建进程的资源,进程的最后一个线程死亡的时候会去释放进程的资源。
进程死亡的过程可以细分为两步,僵尸和火化,对应着进程死亡的两个子状态EXIT_ZOMBIE和EXIT_DEAD。进程只有被火化之后才算是彻底死亡了。就像人死了需要被家属送去殡仪馆火化并注销户口一样,进程死了也需要亲属送去火化并注销户口。僵尸进程虽然已经死了,但是并没有火化和注销户口,此时进程的各种状态信息还能查得到,进程被火化之后其户口也就自动注销了,内核中的相关函数、proc文件系统以及ps命令就查不到它的信息了。
对于进程来说只有父进程有权力去火化子进程,如果父进程一直不去火化子进程,那么子进程就会一直处于僵尸状态。父进程火化子进程的方法的是什么呢?就是系统调用wait、waitid、waitpid、wait3、wait4。
如果父进程提前死了怎么办呢?子进程会被托孤给init进程,由init进程负责对其火化。任何进程死亡都会经历僵尸状态,只不过大部分情况下这个状态持续时间都非常短,用户空间感觉不到。当父进程没有对子进程wait的时候,子进程就会一直处于僵尸状态,不会被火化,这时候用户空间通过ps命令就可以看到僵尸状态的进程了。僵尸进程不是没有死,而是死了没人送去火化,所以杀死僵尸进程的说法是不对的。清理僵尸进程的方法是kill其父进程,父进程死了,僵尸进程会被托孤给init进程,init进程会立马对其进行火化。
当一个进程的exit_group执行完成之后,这个进程就变成了僵尸进程。僵尸进程是没有用户空间的,也不可能再执行了。僵尸进程的文件等所有资源都被释放了,唯一剩下的就是还有一个task_struct结构体。如果父进程此时去wait子进程或者之前就已经在wait子进程,此时wait会返回,task_struct会被销毁,这个进程就彻底消失了。
3.7 线程的死亡
线程的单独死亡也就是线程死了进程没死的情况,只存在正常死亡,不存在非正常死亡,因为线程如果非正常死亡则进程一定也会死亡。线程的正常死亡有以下几种方法:
- 线程函数return。
- 线程函数调用了pthread_exit。
- 线程被其他线程取消了,并且执行到了取消点或者是异步取消。
线程的正常死亡从逻辑上又可以分为主动死亡和被动死亡,主动死亡是线程自己的事做完了或者遇到了一些情况而选择死亡,被动死亡是被其他线程要求死亡。第一个方法和第二个方法可以用于主动死亡,也可以用于被动死亡,第三个方法只用于被动死亡。线程的主动死亡很简单,线程函数执行完线程就死了,或者线程主动调用pthread_exit,线程也就死了。
线程的被动死亡比较麻烦,被动死亡是异步的,线程被要求死亡时线程执行到哪里了是不确定的。线程在很多点是不能直接死亡的,因为线程可能还持有锁、文件描述符等资源,如果直接死亡会造成资源泄露,因此在死亡前必须把这些资源都释放掉,但是对于被动死亡来说,这是比较难做到的。一般情况下,我们应当尽量避免线程被动死亡的情况,如果不得不被动死亡的情况下,我们可以这么设计:
⑴在线程函数里有个大循环,在循环的末尾检查一个flag,比如名叫exit的全局bool变量,如果为TRUE,则break退出循环,然后线程执行到末尾,线程就死了,exit的初始值为FALSE,其他线程可以在适当的时机把这个变量赋值为TRUE,从而达到让一个线程去死的目的。要注意资源的获取与释放要配对,比如可以在循环体内部的开头与末尾进行获取和释放,也可以在线程函数的开头与末尾进行获取与释放。
⑵线程函数不是一个大循环,而是层次很深的调用,可以在某个或者几个较深层次的函数中根据一个flag状态,选择调用pthread_exit来结束线程,其他线程通过改变这个flag状态达到让这个线程去死的目的。要注意在调用pthread_exit之前要把所有的资源都释放掉。
⑶利用POSIX接口提供的取消点机制来实现。pthread_cancel可以用来给一个线程发送死亡请求,这是一种优雅的赐死方法,这个函数并不会直接杀死线程,而是只发送了一个赐死的命令,线程可以自己设置是否响应请求以及自杀的时间点。大家要注意区分pthread_cancel和pthread_kill这两个函数,pthread_kill从名称上看仿佛是要杀死一个线程,但是实际上并不是,它只是能定向的给一个线程发送信号而已,是kill函数的线程版。
pthread_kill如果发送的是一个死亡信号,这个信号并不会只让这个线程死,而是会导致整个进程都会死,所以实现不了只让线程死的效果。pthread_cancel这个函数直译的话是线程取消,会让人摸不着头脑,它的实际含义是给一个线程发送死亡请求。
那么线程如何响应别人给自己发送的死亡请求呢?
线程首先可以通过函数pthread_setcancelstate来设置自己是否接受别人的死亡请求。pthread_setcancelstate的第一个参数有两个取值,分别是PTHREAD_CANCEL_ENABLE和PTHREAD_CANCEL_DISABLE,PTHREAD_CANCEL_ENABLE是接受别人的死亡请求的意思,PTHREAD_CANCEL_DISABLE是暂时不接受别人的死亡请求的意思,死亡请求会一直处于pendding状态,并不会消失,当线程再次设置PTHREAD_CANCEL_ENABLE时,死亡请求还是要被执行。
当通过pthread_setcancelstate设置了PTHREAD_CANCEL_ENABLE之后,还可以通pthread_setcanceltype来设置死亡的方式,是收到死亡请求之后立马就自杀,还是先处理好后事再从容的去死。pthread_setcanceltype的第一个参数有两个取值,分别是PTHREAD_CANCEL_ASYNCHRONOUS和PTHREAD_CANCEL_DEFERRED,PTHREAD_CANCEL_ASYNCHRONOUS的意思是随时可以去死(一般情况下线程会在收到死亡请求之后立马去死,但是可能会因为系统延迟等原因会延迟一些时间才去死),PTHREAD_CANCEL_DEFERRED的意思是先等一下,等我处理好后事再去死。
那么PTHREAD_CANCEL_DEFERRED是要等到什么时候呢,要等到一个叫做取消点的地方,有很多系统调用是取消点,执行这些系统调用时会响应死亡请求,线程就会去死。除此之外,还有一个函数pthread_testcancel也是一个取消点,它是专门用来添加到代码中来增加取消点的,以防止代码中没有取消点或者取消点过少而不能及时响应死亡请求。我们可以在一些能够安全执行线程死亡的地方添加对pthread_testcancel函数的调用。
取消点的作用是什么呢,取消点的目的是避免一个函数在执行中途然后线程被杀死了,这是很不安全的,只有在安全点线程才会被杀,这样不会出现函数执行一半的地方。但是线程执行到取消点的时候,此时仍然可能持有锁等资源,取消点并不能解决这个问题,因此还有一个函数pthread_cleanup_push用来设置清理函数,取消点中执行死亡请求的时候会执行清理函数,你要在清理函数中释放资源。
大家看到了吧,取消点机制是不是非常麻烦,所以很多学计算机的人都不知道有取消点机制,而且也几乎没有博客讲取消点的,就连Android上的libc实现bionic都选择不实现取消点,麻烦而且没人用。大家如果想要线程被动死亡,优先选择使用前两种方法。
⑷还有一种实现线程被动死亡的错误方法,就是使用pthread_kill给目标线程发送SIGUSR1信号,由于SIGUSR1是自定义信号,系统默认的处理是忽略,并不会造成进程死亡。因此我们可以通过sigaction设置信号处理函数,然后在信号处理函数中调用pthread_exit,这样目标线程就会死亡。信号处理函数是进程全局的,并不用每个线程都设置一遍,只要某个线程设置了,所有线程的信号处理函数都是这个函数。这个方法确实能实现被动死亡的效果,但是却存在着很大的问题,就是线程持有的资源可能得不到释放。
进程状态迁移
进程主要有七种状态:就绪状态、运行状态、轻度睡眠、中度睡眠、深度睡眠、僵尸状态、死亡状态,状态之间会根据不同情况进行变迁。
⑴运行状态
运行状态表示进程正在运行或者正在等待 CPU 的时间片。一个进程已经准备就绪,可以随时被系统调度此时就是运行状态。相当于合并了操作系统的就绪和运行两种状态统称运行状态。如果是一个进程,CPU 可能直接就分配足够的资源从而就被执行掉了。但是,当许多进程被调度后,CPU 的资源就不够分配了,从就绪队列调度后仍然存在一个队列 —— 运行队列。这个运行队列队头指针 head 所指向就是第一个进程所对应的 task_struct,队尾指针 tail 所指向就是最后一个所对应的 task_struct。
所以我们要运行某一个进程只需要将 head 所指向的那个进程放到 CPU 上去运行即可,但是 CPU 无法直接找到需要运行的进程,需要借助调度器。具体流程就是,CPU-> 运行队列 -> 调度器。这些存在运行队列中的进程都被称作运行状态。时间片:一个进程被拿到 cup 中执行时,并不是等这个进程执行完毕才切换下一个进程。而是这多个进程在一个时间段内所有代码都会被执行 —— 这就叫做并发执行,每一个进程执行一个时间片的时间后就会从 CPU 上拿下来,然后放上下一个进程。这个动作就称作为进程切换。一个时间片通常是 10ms 左右。
⑵浅度睡眠状态
浅度睡眠状态也称为可中断睡眠状态,进程因等待某个条件(如 I/O 完成、互斥锁释放或某个事件发生)而无法继续执行。在这种情况下,进程会进入阻塞状态,在阻塞状态下,进程不能继续执行,直到等待的事件发生并且操作系统再次将其转换为就绪状态。
阻塞状态:在 CPU 的运行队列中就是运行状态,但是这时候你需要访问外设,操作系统就会把你移动到该外设的等待队列,而不是留在 CPU 的队列中,CPU 不会等待你,此时你的状态就是阻塞。阻塞状态下的进程不会占用 CPU 资源,操作系统会调度其他可执行的进程。例如,在调用 sleep 函数时,进程会进入阻塞状态,等待指定的时间间隔过去。阻塞状态就是当前你的状态不能被 CPU 调度,而是在等待某种资源。
⑶深度睡眠状态
深度睡眠状态也称为不可中断睡眠状态。除了不会因为收到信号而被唤醒,该状态与可中断睡眠状态类似。但处于该状态的进程只有被使用 wake_up () 函数明确唤醒时才能转换到可运行的就绪状态。该状态通常在进程需要不受干扰地等待或者所等待事件会很快发生时使用。
⑷僵尸状态
当进程已停止运行,但其父进程还没有调用 wait () 询问其状态时,则称该进程处于僵死状态。为了让父进程能够获取其停止运行的信息,此时子进程的任务数据结构信息还需要保留着。一旦父进程调用 wait () 取得了子进程的信息,则处于该状态进程的任务数据结构就会被释放。
⑸死亡状态
死亡状态是指进程执行完毕或者因为某些原因被终止的状态,在任务列表中看不到。
⑹轻度睡眠、中度睡眠
目前暂未有明确的关于轻度睡眠和中度睡眠状态的标准定义,但可以推测它们可能是介于浅度睡眠和深度睡眠之间的不同程度的睡眠状态,可能在可中断性和唤醒条件上有所不同。
⑺状态变迁
进程在其生命周期内会根据不同的情况在各种状态之间进行变迁。例如,当系统资源已经可用时,处于睡眠等待状态的进程就被唤醒而进入准备运行状态,即就绪态;当进程的运行时间片用完,系统就会使用调度程序强制切换到其他的进程去执行,此时该进程可能进入睡眠状态等。
进程调度策略和优先级
5.1支持的调度策略
Linux 内核支持多种调度策略,以满足不同类型进程的需求。其中,先进先出(SCHED_FIFO)和轮流调度(SCHED_RR)、限期调度策略(SCHED_DEADLINE)主要用于调度实时进程。
先进先出调度策略按照进程进入就绪队列的先后顺序进行调度,一旦一个进程开始执行,它将一直运行直到主动让出 CPU 或者被更高优先级的进程抢占。轮流调度则在同优先级的实时进程之间采用时间片轮转的方式进行调度,当一个进程的时间片用完后,它会被放到同优先级队列的尾部,等待下一次调度。限期调度策略把进程按照绝对截止期限从小到大在红黑树中进行排序,每次选择截止期限最小的进程执行。
对于普通进程,Linux 内核支持标准轮流分时(SCHED_NORMAL)和 SCHED_BATCH 两种调度策略。SCHED_NORMAL 是完全公平调度 CFS 的一种实现,引入了虚拟运行时间的概念,给每个进程设置一个虚拟运行时间,调度器会选择虚拟运行时间最小的进程来执行,同时不同优先级的进程其虚拟运行时间增长速度不同,优先级高的进程虚拟运行时间增长得慢,所以它可能得到更多的运行机会。SCHED_BATCH 适用于批处理任务,减少交互性以提高吞吐量。
空闲(SCHED_IDLE)调度策略在系统空闲时调用 idle 进程,每个 CPU 上都有一个空闲线程即 0 号线程,优先级最低,只有在其它类型的调度类进程都执行完毕后,才会执行空闲调度类对应的进程。
5.2进程优先级
在 Linux 内核中,限期进程优先级高于实时进程,实时进程高于普通进程。
普通进程优先级可通过修改 nice 值改变,优先级等于 120 加上 nice 值。nice 值的范围是 -20 到 19,数值越小表示优先级越高。通过 nice 命令可以启动一个进程并设置其优先级,也可以使用 renice 命令修改已经运行的进程的优先级。
此外,Linux 还提供了 chrt 命令用于设置实时优先级。实时优先级范围从 1 到 99,1 为最低优先级,99 为最高优先级。非实时调度策略通常使用 nice 值进行优先级调整。
写时复制
写时复制(Copy-on-Write,COW)是一种高效的内存管理技术,其核心思想是只有在不得不复制数据内容时才采取复制数据内容的操作。
在 Linux 系统中,写时复制技术最初产生于 Unix 系统,用于实现一种更为高效的进程创建方式。传统的 fork () 系统调用直接把所有的资源复制给新创建的进程,这种实现方式效率低下,因为它拷贝的数据或许可以共享。而 Linux 的 fork () 使用写时拷贝(copy-on-write)页实现,这种技术可以推迟甚至避免拷贝数据。
内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只有在需要写入的时候才会复制地址空间,从而使各个进程拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。
例如,在 fork () 后立即执行 exec () 的情况下,地址空间就无需被复制了。fork () 的实际开销就是复制父进程的页表以及给子进程创建一个进程描述符。在一般情况下,进程创建后都为马上运行一个可执行的文件,这种优化,可以避免拷贝大量根本就不会被使用的数据。
当父进程调用 fork () 创建子进程时,内核会将父进程的所有内存页都标记为只读,并增加每个页面的引用计数。一旦其中一个进程尝试写入某个内存页,就会触发一个保护故障(缺页异常),此时会陷入内核,内核将拦截这个写入操作,检查该页面的引用数。如果引用数大于 1,则会创建该页面的副本,并将引用数减 1,同时恢复这个页面的可写权限,然后重新执行这个写操作;如果页面的引用数只有 1,那么内核就可以跳过分配新页面的步骤,直接修改该页面,将其标记为可写。
写时拷贝技术实际上是运用了一个 “引用计数” 的概念来实现的。在开辟的空间中多维护四个字节来存储引用计数。当我们多开辟一份空间时,让引用计数 +1,如果有释放空间,那就让计数 -1,但是此时不是真正的释放,是假释放,等到引用计数变为 0 时,才会真正的释放空间。如果有修改或写的操作,那么也让原空间的引用计数 -1,并且真正开辟新的空间。
写时复制技术在很多场景中都有应用。例如 Redis 数据持久化、数据复制(主从复制)、集群伸缩等场景中,Redis 会 fork 出一个子进程来执行相应的操作。这时 Redis 主进程和子进程会共享同一份内存页面,直到子进程需要写入数据时才会复制页面。在文件拷贝、快照和克隆等场景中,操作系统也会使用写时复制技术,只有当其中一个文件被修改时,操作系统才会为其创建一个副本。
进程监控和管理工具
7.1top 命令
top 命令是一个实时监控进程的工具,它可以显示系统中进程的 CPU 和内存占用情况,以及进程的状态。通过 top 命令可以观察到当前系统时间、机器运行时长、系统平均负载、CPU 的运行负载、内存的使用率、IO 的负载等信息。具体来说,top 命令会显示系统当前时间、系统到目前为止已运行的时间、当前登录系统的用户数量、系统负载(任务队列的平均长度)三个值分别为 1 分钟、5 分钟、15 分钟前到现在的平均值。还会显示所有启动的进程数、正在运行的进程数、挂起的进程数、停止的进程数、僵尸进程数。
同时,会展示用户空间和内核空间占用 CPU 的百分比、用户空间内改变过优先级的进程占用 CPU 百分比、空闲 CPU 百分比、等待输入输出 CPU 时间百分比等信息。此外,还会显示物理内存总量、已使用的物理内存、空闲物理内存、内核缓存内存量以及交换区总量、已使用交互区总量、空闲交换区总量、缓冲的交换区总量等内存相关信息。
最后,会以 “PID USER PR NI VIRT RES SHR S % CPU % MEM TIME+ COMMAND” 的格式显示进程的详细信息,包括进程 ID、进程所有者、优先级、nice 值、进程使用的虚拟内存总量、进程使用的未被换出的物理内存大小、共享内存大小、进程状态、上次更新到现在的 CPU 时间占用百分比、进程使用的物理内存百分比、进程使用 CPU 总时间和命令名、命令行等。
7.2ps 命令
ps 命令用于列出当前用户的进程信息,有多种执行风格。可以显示进程的各种属性,如进程 ID、状态、资源使用等。直接输入 ps,显示当前进程,输出结果包含 PID、TTY、TIME、CMD 四列信息,分别代表进程 ID、终端类型、进程运行过程中占用 CPU 的总时间、启动进程的命令的名称。常用的组合有 ps aux 显示所有进程的详细信息,ps -ef 类似于 aux,缺少 cpu%、mem%。还可以根据进程编号查询 CMD 命令,如 ps -p 3245 -o comm = 查找 PID3245 对应的命令名称;根据命令查找 pid,如 ps -C sleep -o pid=。
7.3kill 命令
kill 命令用于终止指定 PID 的进程。Linux 中的 kill 命令用来终止指定的进程的运行,是 Linux 下进程管理的常用命令。通常,终止一个前台进程可以使用 Ctrl+C 键,但是,对于一个后台进程就须用 kill 命令来终止,我们就需要先使用 ps/pidof/pstree/top 等工具获取进程 PID,然后使用 kill 命令来杀掉该进程。kill 命令是通过向进程发送指定的信号来结束相应进程的。在默认情况下,采用编号为 15 的 TERM 信号。
TERM 信号将终止所有不能捕获该信号的进程。对于那些可以捕获该信号的进程就要用编号为 9 的 kill 信号,强行 “杀掉” 该进程。常用的方法有 kill -STOP [pid] 发送 SIGSTOP (17,19,23) 停止一个进程,而并不消灭这个进程;kill -CONT [pid] 发送 SIGCONT (19,18,25) 重新开始一个停止的进程;kill -KILL [pid] 发送 SIGKILL (9) 强迫进程即时停止,并且不实施清理操作;kill -9 -1 终止你拥有的全部进程。
7.4nice 和 renice 命令
nice 和 renice 命令用于调整进程的优先级,平衡系统资源分配。在 Linux 系统中,普通进程优先级可通过修改 nice 值改变,优先级等于 120 加上 nice 值。nice 值的范围是 -20 到 19,数值越小表示优先级越高。通过 nice 命令可以启动一个进程并设置其优先级,在命令行中输入”nice -n priority command”,其中 priority 为一个整数值,范围为 -20 到 19,数值越小表示优先级越高。例如 nice -n 10./myprogram 上述命令将启动一个名为”myprogram” 的程序,并设置其优先级为 10。
renice 命令可以修改已经运行的进程的优先级,在命令行中输入”renice -n priority PID”,其中 priority 为新的优先级值,PID 为进程的 ID。例如 renice -n 5 1234 上述命令将进程 ID 为 1234 的进程的优先级修改为 5。在 top 命令输出中,有一列的字段名为 NI,NI 标记了进程的优先级。使用 root 用户设置时,该字段的取值范围为 -20~19,数值越低代表优先级越高。如果进程在启动时未设定 nice 优先级,默认为 0。普通用户可以设置自己的进程 nice 优先级,取值范围为 0~19。在 top 命令输出中,还有一个字段为 PR,PR 也代表为优先级,但实际上 linux 使用了’动态优先级’的调度算法来确定每个进程的优先级,一个进程的最终优先级 = 优先级 + nice 优先级。
7.5nohup 命令
nohup 命令使进程在后台持续运行,不受终端关闭的影响。当我们在终端执行一个命令时,如果关闭终端,该命令通常会被终止。但是使用 nohup 命令后,该进程会在后台持续运行,即使终端关闭也不会受到影响。例如,nohup command & 会在后台启动 command 命令,并使用 nohup 使其持续运行。输出会被重定向到 nohup.out 文件中,如果该文件已经存在,会被追加内容。
进程资源限制与管理
在 Linux 系统中,进程资源限制是一种管理策略,用于控制进程使用系统资源的上限,防止某个进程耗尽系统资源,确保系统的稳定性和其他进程的正常运行。通过设置资源限制,可以对进程的 CPU 时间、内存使用、打开文件数等多种资源进行管控。
8.1ulimit 命令
ulimit 命令是 bash 内置命令,主要用于设置和管理用户对系统资源的限制。它由 PAM 模块在登录和会话启动时强制实施,限制了 shell 及其子进程可用的资源。
ulimit 如何限制资源:在 /etc/pam.d/system-auth 文件中调用了 pam_limits 模块,此模块读取 /etc/security/limits.conf 和 /etc/security/limits.d/,按配置文件设置资源限制。普通用户可以设置自己的软限制,但不能高于硬限制。可以使用 ulimit -a 查看资源限制列表,软限制是一个警告阈值,当达到或超过该限制时,系统会发出警告信息,但不会阻止用户登录;硬限制是一个严格的限制,当达到或超过该限制时,系统将阻止用户登录。
例如,查看当前允许打开的最大文件描述符数量可以使用 ulimit -n,尝试修改最大的文件描述符数量:ulimit -n 10240。修改 ulimit 值只会对当前 shell 会话有效,要永久更改则需要修改文件 /etc/security/limit.conf。
8.2cgroup 控制组
cgroups(控制组)是 Linux 内核中的一项功能,用于对进程进行分组管理和限制。它可以实现对 CPU、内存、磁盘 I/O 等资源的细粒度控制,提供更强大的资源管理能力。
Cgroups简介
Cgroups 可以限制、统计和分离一个进程组的资源,如 CPU、内存、磁盘输入输出等。如果一个进程加入了某一个控制组,该控制组对 Linux 的系统资源都有严格的限制,进程在使用这些资源时,不能超过其最大的限制数。
Cgroups 中的资源控制都是以控制族群为单位实现。一个进程可以加入到多个控制族群。
Cgroups 涉及任务、控制族群、层级、子系统等概念。任务就是系统的一个进程;控制族群是一组按照某种资源限制划分的进程;层级是控制族群可以组织成 hierarchical 的形式,子节点控制族群继承父节点的特定属性;子系统是任务组中的一个模块,用于管理特定资源类型。
Cgroups 子系统介绍
- blkio:为块设备设定输入 / 输出限制。
- cpu:使用调度程序提供对 CPU 的 cgroup 任务访问。
- cpuacct:自动生成 cgroup 中任务所使用的 CPU 资源报告。
- cpuset:为 cgroup 中的任务分配独立 CPU(在多核系统)和内存节点。
- devices:可允许或者拒绝中的任务对设备的访问。
- freezer:挂起或者恢复 cgroup 中的任务。
- memory:设定 cgroup 中任务使用的内存限制,并自动生成任务使用的内存资源报告。
- net_cls:使用等级识别符标记网络数据包,可允许 Linux 流量控制程序识别从具体 cgroup 中生成的数据包。
- net_prio:允许管理员动态的通过各种应用程序设置网络传输的优先级。
- HugeTLB:HugeTLB 页的资源控制功能。
Cgroups 在 Linux 中的使用
- 检查系统是否支持 cgroups:运行 lssubsys -a 命令,查看系统支持的 cgroup 子系统。
- 创建 cgroup:创建一个新的 cgroup,以便将进程添加到该组并限制其资源。例如,创建一个名为 my_cgroup 的 cgroup,可以在 /sys/fs/cgroup/cpu 目录下创建一个名为 my_cgroup 的子目录。
- 设置资源限制:通过修改 cgroup 对应的文件系统中的文件来设置资源限制。例如,设置 CPU 限制可以使用 echo 50000 >> /sys/fs/cgroup/cpu/my_cgroup/cpu.cfs_quota_us 和 echo 100000 >> /sys/fs/cgroup/cpu/my_cgroup/cpu.cfs_period_us。
- 将进程加入 cgroup:使用 echo <pid> >> /sys/fs/cgroup/<controller>/my_cgroup/cgroup.procs 将进程加入 cgroup。
守护进程
9.1守护进程的定义和特点
守护进程也称精灵进程,是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。守护进程的特点如下:
- 运行周期长;
- 在后台运行;
- 不与用户交互。
在 Linux 系统中,守护进程通常被用于执行系统服务、监控任务等。例如,Internet 服务器 inetd、Web 服务器 httpd 等都是守护进程。守护进程不受用户登录注销的影响,一直运行着,即使控制终端被关闭,守护进程也能继续运行
9.2编写守护进程的步骤
①创建子进程,退出父进程:使用fork()函数创建子进程,然后让父进程退出。这样做的目的是让所有工作在子进程中进行,使子进程脱离控制终端,形式上脱离了控制终端。如果进程是以 shell 命令方式从前台启动,从父进程终止时,shell 就认为命令已经完成,这可以自动使子进程在后台运行。子进程继承了父进程的进程组号,但它拥有自己的进程号,这就保证了此子进程不是进程组长,可以进行下一步创建新会话。
代码语言:javascript代码运行次数:0运行复制 pid_t pid = fork();
if (pid!= 0) {
exit(0);
}
②在子进程中创建新会话:使用setsid()函数在子进程中创建新会话,使子进程完全独立出来,脱离控制。调用setsid()的进程,既是新会话的首进程,也是新进程组的组长进程,并且没有控制终端。如果当前进程原本有一个控制终端,则它失去这个控制终端,成为一个没有控制终端的进程。
代码语言:javascript代码运行次数:0运行复制 setsid();
③再次创建子进程,失去进程组长身份:再次使用fork()函数,退出父进程,使子进程失去进程组长身份,成为普通组员进程。第二次fork的目的是确保守护进程即使打开一个终端设备,也不会自动获得控制终端。
代码语言:javascript代码运行次数:0运行复制pid = fork();
if (pid!= 0) {
exit(0);
}
④改变当前工作目录至根目录下:使用chdir("/")函数改变当前工作目录至根目录下。守护进程一直在后台运行,其工作目录不能被卸载,重新设定当前工作目录可以防止占用可卸载的文件系统。
代码语言:javascript代码运行次数:0运行复制 chdir("/");
⑤重设文件权限掩码:使用umask(0)函数将文件掩码清零,将文件模式创建屏蔽字设置为 0,拒绝某些权限,增加守护进程灵活性。
代码语言:javascript代码运行次数:0运行复制 umask(0);
⑥关闭文件描述符:关闭守护进程从运行它的进程(通常为 shell)继承而来打开的文件描述符。可以使用getdtablesize()函数获取当前进程打开文件的数目,然后遍历关闭所有文件描述符。
9.3守护进程的启动和停止方法
守护进程可以通过systemctl等命令进行管理,也可以使用一些特定的工具和方法进行启动和停止。
①使用nohup命令启动守护进程:
nohup命令可以使进程在后台持续运行,不受终端关闭的影响。例如,nohup command &会在后台启动command命令,并使用nohup使其持续运行。输出会被重定向到nohup.out文件中,如果该文件已经存在,会被追加内容。
②使用start-stop-daemon工具:
start-stop-daemon是一个用于启动或停止系统守护进程的工具。它可以通过指定不同的选项来控制守护进程的启动和停止。例如,可以使用--start选项启动守护进程,使用--stop选项停止守护进程,使用--pidfile选项指定进程的 PID 文件等。
③使用红帽服务管理器启动守护进程:
在红帽 Linux 系统中,可以使用红帽服务管理器来启动守护进程。首先需要编写一个符合红帽服务管理器要求的服务脚本,这个脚本通常包括了指定服务的名称、描述、启动命令、停止命令等信息。在脚本编写完成后,将其保存在指定的目录下,通常是 “/etc/init.d/” 目录中。然后,可以使用红帽服务管理器提供的命令来管理这个服务脚本。通过执行 “service start” 命令,就可以启动对应的守护进程;而执行 “service stop” 命令则可以停止守护进程的运行。此外,还可以使用 “chkconfig” 命令来设置守护进程在系统启动时是否自动运行,以及在哪些运行级别下启动。
案例分析与最近实践
10.1多进程编程实例
在实际编程中,多进程技术可以提高程序的并发性和效率。例如,在处理大量数据时,可以将任务分配给多个进程同时进行处理。以下是一个简单的多进程编程示例:
代码语言:javascript代码运行次数:0运行复制import multiprocessing
def worker():
print("Worker process is running.")
if __name__ == '__main__':
processes = []
for _ in range(5):
p = multiprocessing.Process(target=worker)
processes.append(p)
p.start()
for p in processes:
p.join()
这个示例创建了五个子进程,每个子进程都执行worker函数。通过使用多进程,可以充分利用多核处理器的优势,提高程序的执行速度。
10.2进程监控与自动重启
在 Linux 中,可以使用多种方法实现进程监控与自动重启。一种常见的方法是使用脚本实现,通过定期检查进程是否存在,如果不存在则重新启动。例如,可以使用ps -ef | grep “$1″ | grep -v “grep” | wc –l来获取指定进程的数量,根据进程数量来决定是否需要重新启动进程。这种方法可以通过一个死循环和定时检查来实现,也可以使用crontab来定期执行脚本。
另一种方法是使用exec+fork方式。首先使用fork系统调用创建子进程,在子进程中使用exec函数执行需要自动重启的程序。在父进程中执行wait等待子进程的结束,然后重新创建一个新的子进程。这样可以实现对特定程序的自动监控和重启。以下是一个使用exec+fork方式实现的示例代码:
代码语言:javascript代码运行次数:0运行复制#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
int main(int argc,char**argv)
{
int ret, i, status;
char*child_argv[100]={0};
pid_t pid;
if(argc <2){
fprintf(stderr,"Usage:%s \n", argv[0]);
return -1;
}
for(i = 1; i< argc;++i){
child_argv[i-1]=(char*)malloc(strlen(argv[i])+1);
strncpy(child_argv[i-1], argv[i], strlen(argv[i]));
child_argv[i-1][strlen(argv[i])]='\0';
}
while(1){
pid = fork();
if(pid == -1){
fprintf(stderr,"fork() error.errno:%d error:%s\n", errno, strerror(errno));
break;
}
if(pid ==0){
ret = execv(child_argv[0],(char**)child_argv);
if(ret <0){
fprintf(stderr,"execv ret:%d errno:%d error:%s\n", ret, errno, strerror(errno));
continue;
}
exit(0);
}
if(pid >0){
pid = wait(&status);
fprintf(stdout,"wait return");
}
return 0;
}
}
10.3避免进程泄露和资源耗尽
为了避免进程泄露和资源耗尽,可以采取以下方法:
- 及时清理不再使用的进程:当子进程完成任务后,父进程应及时调用wait或相关函数来获取子进程的退出状态,避免子进程进入僵尸状态。
- 合理管理资源:在程序中,应及时释放不再使用的资源,如文件描述符、内存等。可以使用close函数关闭文件描述符,使用free函数释放动态分配的内存。
- 监控进程状态:可以使用工具如top、ps等定期检查进程的资源使用情况,及时发现资源耗尽的迹象。
- 使用资源限制:可以使用ulimit命令或cgroup控制组来限制进程的资源使用,防止某个进程耗尽系统资源。
例如,可以使用以下代码关闭不再使用的文件描述符:
代码语言:javascript代码运行次数:0运行复制int maxfd = getdtablesize();
int i = 0;
for (; i < maxfd; i++) {
close(i);
}
10.4进程优化技巧
以下是一些优化进程性能的技巧:
- 减少进程创建和销毁的次数:进程的创建和销毁是比较耗时的操作,可以通过复用进程或使用线程来减少进程创建和销毁的次数。
- 合理设置进程优先级:可以使用nice和renice命令调整进程的优先级,使重要的进程获得更多的 CPU 时间。
- 使用写时复制技术:写时复制技术可以减少内存的复制操作,提高进程创建的效率。在 Linux 中,fork函数使用写时复制技术,可以推迟甚至避免拷贝数据。
- 优化进程的 I/O 操作:可以使用异步 I/O 或缓冲 I/O 来减少 I/O 操作的等待时间,提高进程的效率。
- 使用多进程或多线程并行处理:对于可以并行处理的任务,可以使用多进程或多线程来提高程序的执行速度。
例如,可以使用以下代码启动一个进程并设置其优先级:
代码语言:javascript代码运行次数:0运行复制nice -n priority command
其中,priority为一个整数值,范围为 -20 到 19,数值越小表示优先级越高.
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。 原始发表:2025-03-17,如有侵权请联系 cloudcommunity@tencent 删除linux管理函数进程线程【Linux】深入理解进程管理与高效运用
在Linux世界中,进程是在其中扮演着不同角色的演员,有的在默默守护系统安全,有的在进行复杂的运算,有的在解决用户的请求。进程管理则是在幕后管理各个进程的导演。
基础概念
1.1概述
进程是正在运行的程序实例,在Linux内核中,进程被称为任务,例如内核线程与用户线程等。
进程是一个程序的执行实例,也就是正在执行的程序。在操作系统的眼里,进程是一个担当分配系统资源(CPU时间、内存)的实体。操作系统用一个进程控制块的数据结构(进程属性的集合)来描述进程信息,在Linux操作系统下的PCB是task_struct,程序运行时它会被装载到RAM里储存进程信息。task_struct包含标识符、状态。优先级。程序计数器、内存指针、上下文数据。I/O状态数据、记账信息等内容。
可以通过/proc系统文件夹来查看进程信息。要获取PID为多少的进程信息,需要在命令行中输入ls /proc/PID。大多数进程信息可以使用top和ps这些用户级工具获取。
进程有多种状态,例如运行状态(R)、睡眠状态(S)、磁盘休眠级(D)、停止状态(T)、追踪状态(t)、死亡状态(X)、僵尸状态(Z)等。
僵尸进程时处于僵死状态的进程,产生原因时子进程先于父进程退出,父进程没有读取到子进程退出的返回代码,这时候子进程为了保存退出原因,因此进入僵死态不会释放所有资源。僵尸进程会以终止状态保存子啊进程表中,并一直等待父进程读取其退出状态码。会造成资源泄露,内存资源的浪费。
孤儿进程是相对于僵尸进程而言,父进程先于子进程退出,子进程会进入后台运行,成为孤儿进程,孤儿进程随后会被1号init进程领养并回收,也就是将其父进程变为innit进程。
1.2进程四要素
Linux 进程有四个关键要素,分别为有一段程序供其执行、有进程专用的系统堆栈空间、在内核有 task_struct 数据结构、有独立的存储空间拥有专有的用户空间。
不同的要素组合会对应不同类型的进程。如果缺少第四条要素,即有一段程序供其执行、有进程专用的系统堆栈空间、在内核有 task_struct 数据结构但没有独立的存储空间拥有专有的用户空间,这种情况被称为线程。在 Linux 中,线程本质上仍是进程,线程是轻量级的进程,也有 PCB。无论是创建进程的 fork,还是创建线程的 pthread_create,底层都是调用内核函数 clone。若复制(深拷贝,但有 “COW” 优化)父进程地址空间,则为进程;若共享(浅拷贝)父进程地址空间,则为线程。
Linux 内核不区分进程和线程,只在上层应用区分。线程操作函数 pthread_* 是库函数,而非系统调用。线程具有提高并发性、开销小、共享数据方便等优点,但也存在库函数不稳定、调试困难、信号支持不好等缺点。线程间共享资源包括文件描述符表、各信号处理方式、当前工作目录、用户 ID 和组 ID、内存地址空间中的.txt、.data、.bss、.heap、. 共享库等;非共享资源包括线程 id、处理器现场和栈指针(内核栈)、线程栈(用户空间栈)、errno 变量、信号屏蔽字、调度优先级等。
如果完全没有用户空间,则为内核线程。内核线程是直接由内核本身启动的进程,实际上是将内核函数委托给独立的进程,与系统中其他进程 “并行” 执行,内核线程经常被称为内核 “守护进程”。它们主要用于执行如周期性地将修改的内存页与页来源块设备同步、如果内存页很少使用则写入交换区、管理延时动作、实现文件系统的事务日志等任务。内核线程有两种主要类型:一种是线程启动后一直等待,直至内核请求线程执行某一特定操作;另一种是线程启动后按周期性间隔运行,检测特定资源的使用,在用量超出或低于预置的限制时采取行动。
内核线程由内核自身生成,其特点在于它们在 CPU 的管态执行,而不是用户态;只可以访问虚拟地址空间的内核部分(高于 TASK_SIZE 的所有地址),但不能访问用户空间。task_struct 进程描述符中对于普通用户进程来说,mm 指向虚拟地址空间的用户空间部分,而对于内核线程,mm 为 NULL。active_mm 主要用于优化,由于内核线程不与任何特定的用户层进程相关,内核并不需要倒换虚拟地址空间的用户层部分,保留旧设置即可。
假如内核线程之后运行的进程与之前是同一个,内核并不需要修改用户空间地址表,TLB 中信息仍然有效;只有在内核线程之后执行的进程与此前用户层进程不同时,才需要切换,并清除对应 TLB 数据。内核线程可以通过将一个函数传递给 kernel_thread,该函数接下来负责帮助内核调用 daemonize 已转换为守护进程,或者使用辅助函数 kthread_create 创建一个新的内核线程,最初线程是停止的,需要使用 wake_up_process 启动它,或使用 kthread_run 创建新线程后立即唤醒它。
如果共享用户空间映射,则为用户线程。在 Linux 系统中,进程都具有四个要素,缺一条就不成其为进程。如果只具备前三个要素,即有一段程序供其执行、有进程专用的系统堆栈空间、有自己独立的 task_struct 数据结构但共享用户空间,则称为用户线程,也可以看作是个轻量级进程。
进程的实现
明白了进程的基本概念之后,我们来看一看Linux是怎么实现进程的。按照标准的操作系统理论,进程是资源分配的单位,线程是程序执行的单位,内核里用进程控制块(PCB Process Control Block)来管理进程,用线程控制块(TCB Thread Control Block)来管理线程。那么Linux是按照这个逻辑来实现进程的吗?
2.1 基本原理
Linux内核并不是按照标准的操作系统理论来实现进程的,在内核里找不到典型的进程控制块和线程控制块。内核里只有一个task_struct结构体,初学内核的人会很疑惑这是代表进程还是代表线程呢。之所以会这样,是由于历史原因造成的。Linux最开始的时候是不支持多线程的,也可以认为此时一个进程只能有一个线程就是主线程,因此线程就是进程,进程就是线程。所以最初的时候,task_struct既代表进程又代表线程,因为进程和线程没有区别。但是后来Linux也要支持多线程了,多线程的实现方法可以在内核实现,也可以在用户空间实现,也可以同时实现,Linux选择的是在内核实现。
为了最大限度地利用已有的代码,尽量不对代码做大的改动,Linux选择的方法是:task_struct既是线程又是进程的代理。注意这句话,task_struct既是线程又是进程的代理(不是进程本身)。Linux并没有设计单独的进程结构体,而是用task_struct作为进程的代理,这是因为进程是资源分配的单位,线程是程序执行的单位,同一个进程的所有线程共享相同的资源,因此我们让同一个进程下的所有线程(task_struct)都指向相同的资源不就可以了嘛。线程在执行的时候会通过task_struct里面的指针访问资源,同一个进程下的线程自然就会访问到相同的资源,而且这么做还有很大的灵活性。task_struct既是线程又是进程的代理(不是进程本身)。
2.2进程描述符
Linux 内核用 task_struct 结构表示进程,此结构包含表示进程所需的所有数据。其中 state 变量表示任务状态,它可以取多种值,如 TASK_RUNNING 表示进程是可执行的或正在运行;TASK_INTERRUPTIBLE 表示进程正在睡眠但可被信号唤醒;TASK_UNINTERRUPTIBLE 表示进程在睡眠且不可被信号唤醒等。通过修改 state 的值,操作系统可以改变进程的状态,实现任务的管理和调度,从而实现多任务的并发执行。
flags 定义了很多指示符,它用来标识任务的不同状态,如运行、等待、停止等。另外,还有 task_struct->signal,用来管理任务的信号处理。comm 字段存储可执行程序名称,可在 setup_new_exec () 函数进行初始化,也可调用 [gs] et_task_comm () 函数获取,获取时需要用 task_lock () 锁定。tasks 字段提供链接列表能力,Linux 内核将所有进程的进程描述符 task_struct 数据结构链成一个单链表 (task_struct->tasks),这个链表贯穿了整个操作系统的任务管理和调度过程。
mm 和 active_mm 字段表示进程地址空间,对于普通用户进程来说,mm 指向虚拟地址空间的用户空间部分,而对于内核线程,mm 为 NULL。active_mm 主要用于优化,假如内核线程之后运行的进程与之前是同一个,内核并不需要修改用户空间地址表,TLB 中信息仍然有效;只有在内核线程之后执行的进程与此前用户层进程不同时,才需要切换,并清除对应 TLB 数据。
thread_struct thread 结构标识进程存储状态,包含了进程的上下文信息,如程序计数器、寄存器值等。任务的上下文切换是实现任务调度和多任务并发执行的基础,而 thread_struct 提供了保存和恢复任务上下文所需的关键数据。
⑴进程结构体
当我们明白了task_struct既是线程又是进程的代理之后,再来理解task_struct就容易多了。task_struct的字段由两部分组成,一部分是线程相关的,一部分是进程相关的,线程相关的一般是直接内嵌其它数据,进程相关的一般是用指针指向其它数据。线程代表的是执行流,所以task_struct的线程相关部分是和执行有关的,进程代表的是资源分配,所以task_struct的进程相关部分是和资源有关的。我们可以想一下和执行有关的都有哪些,和资源有关的都哪些?可以很轻松地想到,和执行有关的肯定是进程调度相关的数据啊(进程调度虽然叫进程调度,但实际上调度的是线程)。和资源相关的,最重要的首先肯定是虚拟内存啊,其次是文件系统。
下面我们来看一下task_struct的定义:
linux-src/include/linux/sched.h
代码语言:javascript代码运行次数:0运行复制struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
struct thread_info thread_info;
#endif
unsigned int __state;
void *stack;
unsigned int flags;
int on_cpu;
unsigned int cpu;
int recent_used_cpu;
int wake_cpu;
int on_rq;
int prio;
int static_prio;
int normal_prio;
unsigned int rt_priority;
const struct sched_class *sched_class;
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
unsigned int policy;
int nr_cpus_allowed;
cpumask_t cpus_mask;
struct sched_info sched_info;
struct list_head tasks;
struct mm_struct *mm;
struct mm_struct *active_mm;
struct vmacache vmacache;
int exit_state;
int exit_code;
int exit_signal;
pid_t pid;
pid_t tgid;
struct task_struct __rcu *real_parent;
struct task_struct __rcu *parent;
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
unsigned long nvcsw;
unsigned long nivcsw;
u64 start_time;
u64 start_boottime;
unsigned long min_flt;
unsigned long maj_flt;
char comm[TASK_COMM_LEN];
struct fs_struct *fs;
struct files_struct *files;
struct signal_struct *signal;
struct sighand_struct __rcu *sighand;
sigset_t blocked;
sigset_t real_blocked;
sigset_t saved_sigmask;
struct sigpending pending;
struct thread_struct thread;
};
与进程相关的,首先最重要的是虚拟内存空间信息mm、active_mm,这两个都是指针,对于用户线程来说两个指针的值永远都是相同的,同一个进程的所有线程都指向相同的mm,这个值就表明了同一个进程的线程都在同一个用户空间。
其次比较重要的是文件管理相关的两个字段fs和files,也都是指针,fs代表的是文件系统挂载相关的,这个不仅是同进程的所有线程都相同,而且整个系统默认的值都一样,除非使用了mount 命名空间,files代表的是打开的文件资源,这个是同进程的所有线程都相同。然后我们再来看一下信号相关的,信号有的数据是进程全局的,有的是线程私有的,信号的处理是进程全局的,所以signal、sighand两个字段都是指针,同进程的所有线程都指向同一个结构体,信号掩码是线程私有的,所以blocked直接是内嵌数据。
进程相关的数据基本就这些,下面我们来看一下线程相关的数据。首先是进程的运行退出状态,有几个字段,__state、on_cpu、cpu、exit_state、exit_code、exit_signal。然后是和线程调度相关的几个字段,有和优先级相关的rt_priority、static_prio、normal_prio、prio,有和调度信息统计相关的两个结构体,se、sched_info。
⑵进程标识符
task_struct里面有两个重要的字段pid、tgid。我们在用户空间的时候也有pid、tid,那么用户空间的pid是不是就是内核的pid呢,那tgid又是啥呢。很多初学内核的人会认为用户空间的pid就是内核的pid,刚开始我也是这么认为的,给我的内核学习带来了很大的困扰。实际上用户空间的tid是内核空间pid,用户空间的pid是内核空间的tgid,内核空间的tgid是内核里主线程的pid。
为什么会这样呢?主要还是前面讲的问题,task_struct既是线程又是进程的代理,没有单独的进程结构体。当进程创建时,也就是进程的第一个线程创建时,会为task_struct分配一个pid,就是主线程的tid,然后进程的pid也就是字段tgid会被赋值为主线程的tid。此后再创建的线程都会继承父线程的tgid,所以在每个线程中都能直接获取进程的pid。
Linux里面虽然没有进程结构体,但是所有tgid相同、虚拟内存等资源相同的线程构成一个虚拟的进程结构体。创建进程的第一个线程(task_struct)就是同时在创建进程,其对应的mm_struct、files_struct、signal_struct等资源都会被创建出来。创建进程的第二个线程那就是纯粹地创建线程了。
2.3进程的状态
进程的状态在Linux中是如何表示的呢?task_struct中有两个字段用来表示进程的状态,__state和exit_state,前者是总体状态,后者是进程在死亡时的两个子状态。
我们来看一下代码中的定义:linux-src/include/linux/sched.h
代码语言:javascript代码运行次数:0运行复制/* Used in tsk->state: */
#define TASK_RUNNING 0x0000
#define TASK_INTERRUPTIBLE 0x0001
#define TASK_UNINTERRUPTIBLE 0x0002
#define __TASK_STOPPED 0x0004
#define __TASK_TRACED 0x0008
/* Used in tsk->exit_state: */
#define EXIT_DEAD 0x0010
#define EXIT_ZOMBIE 0x0020
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_PARKED 0x0040
#define TASK_DEAD 0x0080
#define TASK_WAKEKILL 0x0100
#define TASK_WAKING 0x0200
#define TASK_NOLOAD 0x0400
#define TASK_NEW 0x0800
其中TASK_RUNNING代表的是Runnable和Running状态。在Linux中不是用flag直接区分Runnable和Running状态的,它们都用TASK_RUNNING表示,区分它们的方法是进程是否在运行队列的当前进程字段上。Blocked状态有两种表示,TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE,它们的区别是前者在睡眠时能被信号唤醒,后者不能被信号唤醒。表示死亡的状态是TASK_DEAD,它有两个子状态EXIT_ZOMBIE、EXIT_DEAD。
进程的生命周期
了解了进程的基本概念,明白了进程在Linux中的实现,下面我们再来看一看进程的生命周期。进程的生命周期和进程的五态转化有关联,但是又不完全相同。
进程从无到有要经历新建的状态,在Linux上创建进程和加载程序是两个不同的步骤。刚创建出来的进程和父进程几乎是一模一样,要想执行新的程序还得经历装载的过程。程序装载完成之后就会进入就绪、执行、阻塞的循环了,这个是进程调度里面的内容。实际上程序在main函数之前还经历了两个过程,分别是so的加载和程序本身的初始化。进程执行到最后总会经历死亡,无论是主动退出还是意外死亡。下面我们就详细分析一下进程的这几个生命周期。
3.1进程创建
在 Linux 内核中,有三种系统调用可用于创建新进程:fork、vfork 和 clone。
fork () 函数是 Linux 最常见的创建进程的方式,在调用 fork () 函数后,系统会为新进程分配资源,例如内存空间,然后复制父进程的全部资源到子进程中,因此新进程和父进程几乎完全一样。此函数的特点是子进程是父进程的复制品,并且父子进程会在不同的内存空间运行。fork () 函数采用定时复制技术,写时复制是其优化策略,在父子进程没有写入操作时,数据是共享的,当任意一方试图写入,便以写时拷贝的方式各自拥有一份副本。
vfork () 函数是为了解决 fork () 函数内存资源需求问题而出现的。它在创建新进程时并不会复制父进程的资源,而是子进程与父进程共享内存空间,只有在子进程结束或调用 exec 系列函数之后,父进程才会恢复运行。此函数的特点是资源共享,节省内存。但由 vfork 创建出来的子进程会导致父进程挂起,除非子进程 exit 或者 execve 才会唤起父进程,且子进程不应该使用 return 返回调用者,或者使用 exit () 退出,可以使用 _exit () 函数来退出。
clone () 函数是 Linux 中更加灵活的创建进程方式。它可以指定共享哪些资源,可以实现和 fork ()、vfork () 相同的功能,也可以用于创建轻量级的线程。此函数的特点是创建进程方式灵活,可定制性强。clone () 函数主要供 pthread 库创建线程,参数多使用复杂,fork 是其简化函数。通过设置不同的 flags 参数,可以控制子进程和父进程共享的资源,例如共享内存空间、文件系统信息、文件描述符等。如果复制(深拷贝,但有 “COW” 优化)父进程地址空间,则为进程;若共享(浅拷贝)父进程地址空间,则为线程。
我们先来看一下fork的接口定义:
代码语言:javascript代码运行次数:0运行复制#include <unistd.h>
pid_t fork(void);
fork系统调用不接受任何参数,返回值是个pid。第一次接触fork的人难免会有疑惑,fork是怎么创建进程的呢?答案是fork会返回两次,在父进程中返回一次,在子进程中返回一次,在父进程中返回的是子进程的pid,在子进程中返回的是0,如果创建进程失败则返回-1。估计很多人还是难以理解这是什么意思。下面我们再举个例子用代码来演示一下。
代码语言:javascript代码运行次数:0运行复制#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main(int argc, char *argv[]){
pid_t pid = fork();
if (pid == -1) {
printf("fork error, exit\n");
exit(-1);
} else if (pid == 0) {
printf("I am child process, pid:%d\n", getpid());
pause();
} else {
printf("I am parent process, pid:%d, my child is pid:%d\n", getpid(), pid);
waitpid(pid, NULL, 0);
}
}
从这个例子中,我们可以看到fork的用法,当fork返回值为0时代表是子进程,我们可以在这里做一些要在子进程中做的事。
那么fork系统调用是怎么实现的呢?让我们来看一下代码:linux-src/kernel/fork.c
代码语言:javascript代码运行次数:0运行复制SYSCALL_DEFINE0(fork){
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
pid_t kernel_clone(struct kernel_clone_args *args){
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
int trace = 0;
pid_t nr;
/*
* For legacy clone() calls, CLONE_PIDFD uses the parent_tid argument
* to return the pidfd. Hence, CLONE_PIDFD and CLONE_PARENT_SETTID are
* mutually exclusive. With clone3() CLONE_PIDFD has grown a separate
* field in struct clone_args and it still doesn't make sense to have
* them both point at the same memory location. Performing this check
* here has the advantage that we don't need to have a separate helper
* to check for legacy clone().
*/
if ((args->flags & CLONE_PIDFD) &&
(args->flags & CLONE_PARENT_SETTID) &&
(args->pidfd == args->parent_tid))
return -EINVAL;
/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
add_latent_entropy();
if (IS_ERR(p))
return PTR_ERR(p);
/*
* Do this prior waking up the new thread - the thread pointer
* might get invalid after that point, if the thread exits quickly.
*/
trace_sched_process_fork(current, p);
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, args->parent_tid);
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
wake_up_new_task(p);
/* forking complete and child started to run, tell ptracer */
if (unlikely(trace))
ptrace_event_pid(trace, pid);
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);
return nr;
}
内核本身有fork的系统调用,但是glibc的fork API是用clone系统调用来实现的,我们知道这一点就行了,实际上它们最后调用的代码还是一样的,所以我们还用fork系统调用来讲解,没有影响。可以看到fork系统调用什么也没做,直接调用的kernel_clone函数,kernel_clone以前叫做do_fork,现在改名了。
kernel_clone的逻辑也很简单,就是做了两件事,一是copy_process复制task_struct,二是wake_up_new_task唤醒新进程。copy_process会根据flag来决定新的task_struct是自己创建新的mm_struct、files_struct等结构体,还是和父线程共享这些结构体,由于我们这里是创建进程,所以这些结构体都会创建新的。系统调用执行完成后就会返回,返回值是子进程的pid。而子进程被wake_up之后会被调度执行,它返回到用户空间时返回值是0。
3.2 进程的装载
新的进程刚刚创建之后执行的还是旧的程序,想要执行新的程序的话还得使用系统调用execve。execve会把当前程序替换为新的程序。下面我们先来看一下execve的接口:
代码语言:javascript代码运行次数:0运行复制#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);
第一个参数是要执行的程序的路径,可以是相对路径也可以是绝对路径。第二个参数是程序的参数列表,我们在命令行执行命令时后面跟的参数会被放到这里。第三个参数是环境变量列表,在命令行执行程序时bash会被自己的环境变量放到这里传给子进程。
除此之外,libc还提供了几个API可以用来执行新的进程,它们的功能是一样的,只是参数有所差异,这些API的实现还是使用的系统调用execve。
代码语言:javascript代码运行次数:0运行复制#include <unistd.h>
extern char **environ;
int execl(const char *pathname, const char *arg, ... /*, (char *) NULL */);
int execlp(const char *file, const char *arg, ... /*, (char *) NULL */);
int execle(const char *pathname, const char *arg, ... /*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
3.3 进程的加载
这一节要讲的是解释器的加载过程,这个过程也被叫做动态链接。加载器的实现是在Glibc里面。我们这里就是大概介绍一下加载器的逻辑,具体的细节大家可以去看参考文献中的书籍。
ELF格式的可执行程序和共享库里面有一个段叫做.dynamic,这个段里面会记录程序所依赖的所有so。so里面的.dynamic段也会记录自己所依赖的所有so。解释器会通过深度优先或者广度优先的方法找到一个程序所依赖的所有so,然后加载它们。
加载一个so会首先解析它的ELF头部信息,然后通过mmap为它的数据段代码段分配内存,并设置不同的读写执行权限。最后会对so进行重定位,重定位包括全局数据重定位和函数重定位。
3.4 进程的初始化
进程完成加载之后不是直接就执行main函数的,而是会执行ELF文件的入口函数。这个入口函数叫做_start,_start完成一些基本的设置之后会调用__libc_start_main。__libc_start_main完成一些初始化之后才会调用main函数。你会发现,我们上学的时候讲的是程序执行的时候会首先执行main函数,实际上在main函数执行之前还发生了很多很复杂的事情,只不过这些事情系统都帮我们悄悄地做了,如果我们想要研究透彻的话还是很麻烦的。
3.5 进程的运行
程序在运行的时候会不停地经历就绪、运行、阻塞的过程。
3.6 进程的死亡
进程执行到最后总会死亡的,进程死亡的原因可以分为两大类,正常死亡和非正常死亡。
正常死亡的情况有:
- 1.main函数返回。
- 2.进程调用了exit、_exit、_Exit 等函数。
- 3.进程的所有线程都调用了pthread_exit。
- 4.主线程调用了pthread_exit,其他线程都从线程函数返回。
非正常死亡的情况有:
- 1.进程访问非法内存而收到信号SIGSEGV。
- 2.库程序发现异常情况给进程发送信号SIGABRT。
- 3.在终端上输入Ctrl+C给进程发送信号SIGINT。
- 4.通过kill命令给进程发送信号SIGTERM。
- 5.通过kill -9命令给进程发送信号SIGKILL。
- 6.进程收到其它一些会导致死亡的信号。
main函数返回本质上也是调用的exit,因为main函数外还有一层函数__libc_start_main,它会在main函数返回后调用exit。exit的实现调用的是系统调用exit_group,pthread_exit的实现调用的是系统调用exit。
这里就体现出了API和系统调用的不同。进程由于信号原因而死的,其死亡方法也是内核在信号处理中调用了系统调用exit_group,只不过是直接调用的函数,没有走系统调用的流程。系统调用exit的作用是杀死线程,系统调用exit_group的作用是杀死当前线程,并给同进程的所有其它线程发SIGKILL信号,这会导致所有的线程都死亡,从而整个进程也就死了。
每个线程死亡的时候都会释放对进程资源的引用,最后一个线程死亡的时候,资源的引用计数会变成0,从而会去释放这个资源。总结一下就是进程的第一个线程创建的时候会去创建进程的资源,进程的最后一个线程死亡的时候会去释放进程的资源。
进程死亡的过程可以细分为两步,僵尸和火化,对应着进程死亡的两个子状态EXIT_ZOMBIE和EXIT_DEAD。进程只有被火化之后才算是彻底死亡了。就像人死了需要被家属送去殡仪馆火化并注销户口一样,进程死了也需要亲属送去火化并注销户口。僵尸进程虽然已经死了,但是并没有火化和注销户口,此时进程的各种状态信息还能查得到,进程被火化之后其户口也就自动注销了,内核中的相关函数、proc文件系统以及ps命令就查不到它的信息了。
对于进程来说只有父进程有权力去火化子进程,如果父进程一直不去火化子进程,那么子进程就会一直处于僵尸状态。父进程火化子进程的方法的是什么呢?就是系统调用wait、waitid、waitpid、wait3、wait4。
如果父进程提前死了怎么办呢?子进程会被托孤给init进程,由init进程负责对其火化。任何进程死亡都会经历僵尸状态,只不过大部分情况下这个状态持续时间都非常短,用户空间感觉不到。当父进程没有对子进程wait的时候,子进程就会一直处于僵尸状态,不会被火化,这时候用户空间通过ps命令就可以看到僵尸状态的进程了。僵尸进程不是没有死,而是死了没人送去火化,所以杀死僵尸进程的说法是不对的。清理僵尸进程的方法是kill其父进程,父进程死了,僵尸进程会被托孤给init进程,init进程会立马对其进行火化。
当一个进程的exit_group执行完成之后,这个进程就变成了僵尸进程。僵尸进程是没有用户空间的,也不可能再执行了。僵尸进程的文件等所有资源都被释放了,唯一剩下的就是还有一个task_struct结构体。如果父进程此时去wait子进程或者之前就已经在wait子进程,此时wait会返回,task_struct会被销毁,这个进程就彻底消失了。
3.7 线程的死亡
线程的单独死亡也就是线程死了进程没死的情况,只存在正常死亡,不存在非正常死亡,因为线程如果非正常死亡则进程一定也会死亡。线程的正常死亡有以下几种方法:
- 线程函数return。
- 线程函数调用了pthread_exit。
- 线程被其他线程取消了,并且执行到了取消点或者是异步取消。
线程的正常死亡从逻辑上又可以分为主动死亡和被动死亡,主动死亡是线程自己的事做完了或者遇到了一些情况而选择死亡,被动死亡是被其他线程要求死亡。第一个方法和第二个方法可以用于主动死亡,也可以用于被动死亡,第三个方法只用于被动死亡。线程的主动死亡很简单,线程函数执行完线程就死了,或者线程主动调用pthread_exit,线程也就死了。
线程的被动死亡比较麻烦,被动死亡是异步的,线程被要求死亡时线程执行到哪里了是不确定的。线程在很多点是不能直接死亡的,因为线程可能还持有锁、文件描述符等资源,如果直接死亡会造成资源泄露,因此在死亡前必须把这些资源都释放掉,但是对于被动死亡来说,这是比较难做到的。一般情况下,我们应当尽量避免线程被动死亡的情况,如果不得不被动死亡的情况下,我们可以这么设计:
⑴在线程函数里有个大循环,在循环的末尾检查一个flag,比如名叫exit的全局bool变量,如果为TRUE,则break退出循环,然后线程执行到末尾,线程就死了,exit的初始值为FALSE,其他线程可以在适当的时机把这个变量赋值为TRUE,从而达到让一个线程去死的目的。要注意资源的获取与释放要配对,比如可以在循环体内部的开头与末尾进行获取和释放,也可以在线程函数的开头与末尾进行获取与释放。
⑵线程函数不是一个大循环,而是层次很深的调用,可以在某个或者几个较深层次的函数中根据一个flag状态,选择调用pthread_exit来结束线程,其他线程通过改变这个flag状态达到让这个线程去死的目的。要注意在调用pthread_exit之前要把所有的资源都释放掉。
⑶利用POSIX接口提供的取消点机制来实现。pthread_cancel可以用来给一个线程发送死亡请求,这是一种优雅的赐死方法,这个函数并不会直接杀死线程,而是只发送了一个赐死的命令,线程可以自己设置是否响应请求以及自杀的时间点。大家要注意区分pthread_cancel和pthread_kill这两个函数,pthread_kill从名称上看仿佛是要杀死一个线程,但是实际上并不是,它只是能定向的给一个线程发送信号而已,是kill函数的线程版。
pthread_kill如果发送的是一个死亡信号,这个信号并不会只让这个线程死,而是会导致整个进程都会死,所以实现不了只让线程死的效果。pthread_cancel这个函数直译的话是线程取消,会让人摸不着头脑,它的实际含义是给一个线程发送死亡请求。
那么线程如何响应别人给自己发送的死亡请求呢?
线程首先可以通过函数pthread_setcancelstate来设置自己是否接受别人的死亡请求。pthread_setcancelstate的第一个参数有两个取值,分别是PTHREAD_CANCEL_ENABLE和PTHREAD_CANCEL_DISABLE,PTHREAD_CANCEL_ENABLE是接受别人的死亡请求的意思,PTHREAD_CANCEL_DISABLE是暂时不接受别人的死亡请求的意思,死亡请求会一直处于pendding状态,并不会消失,当线程再次设置PTHREAD_CANCEL_ENABLE时,死亡请求还是要被执行。
当通过pthread_setcancelstate设置了PTHREAD_CANCEL_ENABLE之后,还可以通pthread_setcanceltype来设置死亡的方式,是收到死亡请求之后立马就自杀,还是先处理好后事再从容的去死。pthread_setcanceltype的第一个参数有两个取值,分别是PTHREAD_CANCEL_ASYNCHRONOUS和PTHREAD_CANCEL_DEFERRED,PTHREAD_CANCEL_ASYNCHRONOUS的意思是随时可以去死(一般情况下线程会在收到死亡请求之后立马去死,但是可能会因为系统延迟等原因会延迟一些时间才去死),PTHREAD_CANCEL_DEFERRED的意思是先等一下,等我处理好后事再去死。
那么PTHREAD_CANCEL_DEFERRED是要等到什么时候呢,要等到一个叫做取消点的地方,有很多系统调用是取消点,执行这些系统调用时会响应死亡请求,线程就会去死。除此之外,还有一个函数pthread_testcancel也是一个取消点,它是专门用来添加到代码中来增加取消点的,以防止代码中没有取消点或者取消点过少而不能及时响应死亡请求。我们可以在一些能够安全执行线程死亡的地方添加对pthread_testcancel函数的调用。
取消点的作用是什么呢,取消点的目的是避免一个函数在执行中途然后线程被杀死了,这是很不安全的,只有在安全点线程才会被杀,这样不会出现函数执行一半的地方。但是线程执行到取消点的时候,此时仍然可能持有锁等资源,取消点并不能解决这个问题,因此还有一个函数pthread_cleanup_push用来设置清理函数,取消点中执行死亡请求的时候会执行清理函数,你要在清理函数中释放资源。
大家看到了吧,取消点机制是不是非常麻烦,所以很多学计算机的人都不知道有取消点机制,而且也几乎没有博客讲取消点的,就连Android上的libc实现bionic都选择不实现取消点,麻烦而且没人用。大家如果想要线程被动死亡,优先选择使用前两种方法。
⑷还有一种实现线程被动死亡的错误方法,就是使用pthread_kill给目标线程发送SIGUSR1信号,由于SIGUSR1是自定义信号,系统默认的处理是忽略,并不会造成进程死亡。因此我们可以通过sigaction设置信号处理函数,然后在信号处理函数中调用pthread_exit,这样目标线程就会死亡。信号处理函数是进程全局的,并不用每个线程都设置一遍,只要某个线程设置了,所有线程的信号处理函数都是这个函数。这个方法确实能实现被动死亡的效果,但是却存在着很大的问题,就是线程持有的资源可能得不到释放。
进程状态迁移
进程主要有七种状态:就绪状态、运行状态、轻度睡眠、中度睡眠、深度睡眠、僵尸状态、死亡状态,状态之间会根据不同情况进行变迁。
⑴运行状态
运行状态表示进程正在运行或者正在等待 CPU 的时间片。一个进程已经准备就绪,可以随时被系统调度此时就是运行状态。相当于合并了操作系统的就绪和运行两种状态统称运行状态。如果是一个进程,CPU 可能直接就分配足够的资源从而就被执行掉了。但是,当许多进程被调度后,CPU 的资源就不够分配了,从就绪队列调度后仍然存在一个队列 —— 运行队列。这个运行队列队头指针 head 所指向就是第一个进程所对应的 task_struct,队尾指针 tail 所指向就是最后一个所对应的 task_struct。
所以我们要运行某一个进程只需要将 head 所指向的那个进程放到 CPU 上去运行即可,但是 CPU 无法直接找到需要运行的进程,需要借助调度器。具体流程就是,CPU-> 运行队列 -> 调度器。这些存在运行队列中的进程都被称作运行状态。时间片:一个进程被拿到 cup 中执行时,并不是等这个进程执行完毕才切换下一个进程。而是这多个进程在一个时间段内所有代码都会被执行 —— 这就叫做并发执行,每一个进程执行一个时间片的时间后就会从 CPU 上拿下来,然后放上下一个进程。这个动作就称作为进程切换。一个时间片通常是 10ms 左右。
⑵浅度睡眠状态
浅度睡眠状态也称为可中断睡眠状态,进程因等待某个条件(如 I/O 完成、互斥锁释放或某个事件发生)而无法继续执行。在这种情况下,进程会进入阻塞状态,在阻塞状态下,进程不能继续执行,直到等待的事件发生并且操作系统再次将其转换为就绪状态。
阻塞状态:在 CPU 的运行队列中就是运行状态,但是这时候你需要访问外设,操作系统就会把你移动到该外设的等待队列,而不是留在 CPU 的队列中,CPU 不会等待你,此时你的状态就是阻塞。阻塞状态下的进程不会占用 CPU 资源,操作系统会调度其他可执行的进程。例如,在调用 sleep 函数时,进程会进入阻塞状态,等待指定的时间间隔过去。阻塞状态就是当前你的状态不能被 CPU 调度,而是在等待某种资源。
⑶深度睡眠状态
深度睡眠状态也称为不可中断睡眠状态。除了不会因为收到信号而被唤醒,该状态与可中断睡眠状态类似。但处于该状态的进程只有被使用 wake_up () 函数明确唤醒时才能转换到可运行的就绪状态。该状态通常在进程需要不受干扰地等待或者所等待事件会很快发生时使用。
⑷僵尸状态
当进程已停止运行,但其父进程还没有调用 wait () 询问其状态时,则称该进程处于僵死状态。为了让父进程能够获取其停止运行的信息,此时子进程的任务数据结构信息还需要保留着。一旦父进程调用 wait () 取得了子进程的信息,则处于该状态进程的任务数据结构就会被释放。
⑸死亡状态
死亡状态是指进程执行完毕或者因为某些原因被终止的状态,在任务列表中看不到。
⑹轻度睡眠、中度睡眠
目前暂未有明确的关于轻度睡眠和中度睡眠状态的标准定义,但可以推测它们可能是介于浅度睡眠和深度睡眠之间的不同程度的睡眠状态,可能在可中断性和唤醒条件上有所不同。
⑺状态变迁
进程在其生命周期内会根据不同的情况在各种状态之间进行变迁。例如,当系统资源已经可用时,处于睡眠等待状态的进程就被唤醒而进入准备运行状态,即就绪态;当进程的运行时间片用完,系统就会使用调度程序强制切换到其他的进程去执行,此时该进程可能进入睡眠状态等。
进程调度策略和优先级
5.1支持的调度策略
Linux 内核支持多种调度策略,以满足不同类型进程的需求。其中,先进先出(SCHED_FIFO)和轮流调度(SCHED_RR)、限期调度策略(SCHED_DEADLINE)主要用于调度实时进程。
先进先出调度策略按照进程进入就绪队列的先后顺序进行调度,一旦一个进程开始执行,它将一直运行直到主动让出 CPU 或者被更高优先级的进程抢占。轮流调度则在同优先级的实时进程之间采用时间片轮转的方式进行调度,当一个进程的时间片用完后,它会被放到同优先级队列的尾部,等待下一次调度。限期调度策略把进程按照绝对截止期限从小到大在红黑树中进行排序,每次选择截止期限最小的进程执行。
对于普通进程,Linux 内核支持标准轮流分时(SCHED_NORMAL)和 SCHED_BATCH 两种调度策略。SCHED_NORMAL 是完全公平调度 CFS 的一种实现,引入了虚拟运行时间的概念,给每个进程设置一个虚拟运行时间,调度器会选择虚拟运行时间最小的进程来执行,同时不同优先级的进程其虚拟运行时间增长速度不同,优先级高的进程虚拟运行时间增长得慢,所以它可能得到更多的运行机会。SCHED_BATCH 适用于批处理任务,减少交互性以提高吞吐量。
空闲(SCHED_IDLE)调度策略在系统空闲时调用 idle 进程,每个 CPU 上都有一个空闲线程即 0 号线程,优先级最低,只有在其它类型的调度类进程都执行完毕后,才会执行空闲调度类对应的进程。
5.2进程优先级
在 Linux 内核中,限期进程优先级高于实时进程,实时进程高于普通进程。
普通进程优先级可通过修改 nice 值改变,优先级等于 120 加上 nice 值。nice 值的范围是 -20 到 19,数值越小表示优先级越高。通过 nice 命令可以启动一个进程并设置其优先级,也可以使用 renice 命令修改已经运行的进程的优先级。
此外,Linux 还提供了 chrt 命令用于设置实时优先级。实时优先级范围从 1 到 99,1 为最低优先级,99 为最高优先级。非实时调度策略通常使用 nice 值进行优先级调整。
写时复制
写时复制(Copy-on-Write,COW)是一种高效的内存管理技术,其核心思想是只有在不得不复制数据内容时才采取复制数据内容的操作。
在 Linux 系统中,写时复制技术最初产生于 Unix 系统,用于实现一种更为高效的进程创建方式。传统的 fork () 系统调用直接把所有的资源复制给新创建的进程,这种实现方式效率低下,因为它拷贝的数据或许可以共享。而 Linux 的 fork () 使用写时拷贝(copy-on-write)页实现,这种技术可以推迟甚至避免拷贝数据。
内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只有在需要写入的时候才会复制地址空间,从而使各个进程拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。
例如,在 fork () 后立即执行 exec () 的情况下,地址空间就无需被复制了。fork () 的实际开销就是复制父进程的页表以及给子进程创建一个进程描述符。在一般情况下,进程创建后都为马上运行一个可执行的文件,这种优化,可以避免拷贝大量根本就不会被使用的数据。
当父进程调用 fork () 创建子进程时,内核会将父进程的所有内存页都标记为只读,并增加每个页面的引用计数。一旦其中一个进程尝试写入某个内存页,就会触发一个保护故障(缺页异常),此时会陷入内核,内核将拦截这个写入操作,检查该页面的引用数。如果引用数大于 1,则会创建该页面的副本,并将引用数减 1,同时恢复这个页面的可写权限,然后重新执行这个写操作;如果页面的引用数只有 1,那么内核就可以跳过分配新页面的步骤,直接修改该页面,将其标记为可写。
写时拷贝技术实际上是运用了一个 “引用计数” 的概念来实现的。在开辟的空间中多维护四个字节来存储引用计数。当我们多开辟一份空间时,让引用计数 +1,如果有释放空间,那就让计数 -1,但是此时不是真正的释放,是假释放,等到引用计数变为 0 时,才会真正的释放空间。如果有修改或写的操作,那么也让原空间的引用计数 -1,并且真正开辟新的空间。
写时复制技术在很多场景中都有应用。例如 Redis 数据持久化、数据复制(主从复制)、集群伸缩等场景中,Redis 会 fork 出一个子进程来执行相应的操作。这时 Redis 主进程和子进程会共享同一份内存页面,直到子进程需要写入数据时才会复制页面。在文件拷贝、快照和克隆等场景中,操作系统也会使用写时复制技术,只有当其中一个文件被修改时,操作系统才会为其创建一个副本。
进程监控和管理工具
7.1top 命令
top 命令是一个实时监控进程的工具,它可以显示系统中进程的 CPU 和内存占用情况,以及进程的状态。通过 top 命令可以观察到当前系统时间、机器运行时长、系统平均负载、CPU 的运行负载、内存的使用率、IO 的负载等信息。具体来说,top 命令会显示系统当前时间、系统到目前为止已运行的时间、当前登录系统的用户数量、系统负载(任务队列的平均长度)三个值分别为 1 分钟、5 分钟、15 分钟前到现在的平均值。还会显示所有启动的进程数、正在运行的进程数、挂起的进程数、停止的进程数、僵尸进程数。
同时,会展示用户空间和内核空间占用 CPU 的百分比、用户空间内改变过优先级的进程占用 CPU 百分比、空闲 CPU 百分比、等待输入输出 CPU 时间百分比等信息。此外,还会显示物理内存总量、已使用的物理内存、空闲物理内存、内核缓存内存量以及交换区总量、已使用交互区总量、空闲交换区总量、缓冲的交换区总量等内存相关信息。
最后,会以 “PID USER PR NI VIRT RES SHR S % CPU % MEM TIME+ COMMAND” 的格式显示进程的详细信息,包括进程 ID、进程所有者、优先级、nice 值、进程使用的虚拟内存总量、进程使用的未被换出的物理内存大小、共享内存大小、进程状态、上次更新到现在的 CPU 时间占用百分比、进程使用的物理内存百分比、进程使用 CPU 总时间和命令名、命令行等。
7.2ps 命令
ps 命令用于列出当前用户的进程信息,有多种执行风格。可以显示进程的各种属性,如进程 ID、状态、资源使用等。直接输入 ps,显示当前进程,输出结果包含 PID、TTY、TIME、CMD 四列信息,分别代表进程 ID、终端类型、进程运行过程中占用 CPU 的总时间、启动进程的命令的名称。常用的组合有 ps aux 显示所有进程的详细信息,ps -ef 类似于 aux,缺少 cpu%、mem%。还可以根据进程编号查询 CMD 命令,如 ps -p 3245 -o comm = 查找 PID3245 对应的命令名称;根据命令查找 pid,如 ps -C sleep -o pid=。
7.3kill 命令
kill 命令用于终止指定 PID 的进程。Linux 中的 kill 命令用来终止指定的进程的运行,是 Linux 下进程管理的常用命令。通常,终止一个前台进程可以使用 Ctrl+C 键,但是,对于一个后台进程就须用 kill 命令来终止,我们就需要先使用 ps/pidof/pstree/top 等工具获取进程 PID,然后使用 kill 命令来杀掉该进程。kill 命令是通过向进程发送指定的信号来结束相应进程的。在默认情况下,采用编号为 15 的 TERM 信号。
TERM 信号将终止所有不能捕获该信号的进程。对于那些可以捕获该信号的进程就要用编号为 9 的 kill 信号,强行 “杀掉” 该进程。常用的方法有 kill -STOP [pid] 发送 SIGSTOP (17,19,23) 停止一个进程,而并不消灭这个进程;kill -CONT [pid] 发送 SIGCONT (19,18,25) 重新开始一个停止的进程;kill -KILL [pid] 发送 SIGKILL (9) 强迫进程即时停止,并且不实施清理操作;kill -9 -1 终止你拥有的全部进程。
7.4nice 和 renice 命令
nice 和 renice 命令用于调整进程的优先级,平衡系统资源分配。在 Linux 系统中,普通进程优先级可通过修改 nice 值改变,优先级等于 120 加上 nice 值。nice 值的范围是 -20 到 19,数值越小表示优先级越高。通过 nice 命令可以启动一个进程并设置其优先级,在命令行中输入”nice -n priority command”,其中 priority 为一个整数值,范围为 -20 到 19,数值越小表示优先级越高。例如 nice -n 10./myprogram 上述命令将启动一个名为”myprogram” 的程序,并设置其优先级为 10。
renice 命令可以修改已经运行的进程的优先级,在命令行中输入”renice -n priority PID”,其中 priority 为新的优先级值,PID 为进程的 ID。例如 renice -n 5 1234 上述命令将进程 ID 为 1234 的进程的优先级修改为 5。在 top 命令输出中,有一列的字段名为 NI,NI 标记了进程的优先级。使用 root 用户设置时,该字段的取值范围为 -20~19,数值越低代表优先级越高。如果进程在启动时未设定 nice 优先级,默认为 0。普通用户可以设置自己的进程 nice 优先级,取值范围为 0~19。在 top 命令输出中,还有一个字段为 PR,PR 也代表为优先级,但实际上 linux 使用了’动态优先级’的调度算法来确定每个进程的优先级,一个进程的最终优先级 = 优先级 + nice 优先级。
7.5nohup 命令
nohup 命令使进程在后台持续运行,不受终端关闭的影响。当我们在终端执行一个命令时,如果关闭终端,该命令通常会被终止。但是使用 nohup 命令后,该进程会在后台持续运行,即使终端关闭也不会受到影响。例如,nohup command & 会在后台启动 command 命令,并使用 nohup 使其持续运行。输出会被重定向到 nohup.out 文件中,如果该文件已经存在,会被追加内容。
进程资源限制与管理
在 Linux 系统中,进程资源限制是一种管理策略,用于控制进程使用系统资源的上限,防止某个进程耗尽系统资源,确保系统的稳定性和其他进程的正常运行。通过设置资源限制,可以对进程的 CPU 时间、内存使用、打开文件数等多种资源进行管控。
8.1ulimit 命令
ulimit 命令是 bash 内置命令,主要用于设置和管理用户对系统资源的限制。它由 PAM 模块在登录和会话启动时强制实施,限制了 shell 及其子进程可用的资源。
ulimit 如何限制资源:在 /etc/pam.d/system-auth 文件中调用了 pam_limits 模块,此模块读取 /etc/security/limits.conf 和 /etc/security/limits.d/,按配置文件设置资源限制。普通用户可以设置自己的软限制,但不能高于硬限制。可以使用 ulimit -a 查看资源限制列表,软限制是一个警告阈值,当达到或超过该限制时,系统会发出警告信息,但不会阻止用户登录;硬限制是一个严格的限制,当达到或超过该限制时,系统将阻止用户登录。
例如,查看当前允许打开的最大文件描述符数量可以使用 ulimit -n,尝试修改最大的文件描述符数量:ulimit -n 10240。修改 ulimit 值只会对当前 shell 会话有效,要永久更改则需要修改文件 /etc/security/limit.conf。
8.2cgroup 控制组
cgroups(控制组)是 Linux 内核中的一项功能,用于对进程进行分组管理和限制。它可以实现对 CPU、内存、磁盘 I/O 等资源的细粒度控制,提供更强大的资源管理能力。
Cgroups简介
Cgroups 可以限制、统计和分离一个进程组的资源,如 CPU、内存、磁盘输入输出等。如果一个进程加入了某一个控制组,该控制组对 Linux 的系统资源都有严格的限制,进程在使用这些资源时,不能超过其最大的限制数。
Cgroups 中的资源控制都是以控制族群为单位实现。一个进程可以加入到多个控制族群。
Cgroups 涉及任务、控制族群、层级、子系统等概念。任务就是系统的一个进程;控制族群是一组按照某种资源限制划分的进程;层级是控制族群可以组织成 hierarchical 的形式,子节点控制族群继承父节点的特定属性;子系统是任务组中的一个模块,用于管理特定资源类型。
Cgroups 子系统介绍
- blkio:为块设备设定输入 / 输出限制。
- cpu:使用调度程序提供对 CPU 的 cgroup 任务访问。
- cpuacct:自动生成 cgroup 中任务所使用的 CPU 资源报告。
- cpuset:为 cgroup 中的任务分配独立 CPU(在多核系统)和内存节点。
- devices:可允许或者拒绝中的任务对设备的访问。
- freezer:挂起或者恢复 cgroup 中的任务。
- memory:设定 cgroup 中任务使用的内存限制,并自动生成任务使用的内存资源报告。
- net_cls:使用等级识别符标记网络数据包,可允许 Linux 流量控制程序识别从具体 cgroup 中生成的数据包。
- net_prio:允许管理员动态的通过各种应用程序设置网络传输的优先级。
- HugeTLB:HugeTLB 页的资源控制功能。
Cgroups 在 Linux 中的使用
- 检查系统是否支持 cgroups:运行 lssubsys -a 命令,查看系统支持的 cgroup 子系统。
- 创建 cgroup:创建一个新的 cgroup,以便将进程添加到该组并限制其资源。例如,创建一个名为 my_cgroup 的 cgroup,可以在 /sys/fs/cgroup/cpu 目录下创建一个名为 my_cgroup 的子目录。
- 设置资源限制:通过修改 cgroup 对应的文件系统中的文件来设置资源限制。例如,设置 CPU 限制可以使用 echo 50000 >> /sys/fs/cgroup/cpu/my_cgroup/cpu.cfs_quota_us 和 echo 100000 >> /sys/fs/cgroup/cpu/my_cgroup/cpu.cfs_period_us。
- 将进程加入 cgroup:使用 echo <pid> >> /sys/fs/cgroup/<controller>/my_cgroup/cgroup.procs 将进程加入 cgroup。
守护进程
9.1守护进程的定义和特点
守护进程也称精灵进程,是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。守护进程的特点如下:
- 运行周期长;
- 在后台运行;
- 不与用户交互。
在 Linux 系统中,守护进程通常被用于执行系统服务、监控任务等。例如,Internet 服务器 inetd、Web 服务器 httpd 等都是守护进程。守护进程不受用户登录注销的影响,一直运行着,即使控制终端被关闭,守护进程也能继续运行
9.2编写守护进程的步骤
①创建子进程,退出父进程:使用fork()函数创建子进程,然后让父进程退出。这样做的目的是让所有工作在子进程中进行,使子进程脱离控制终端,形式上脱离了控制终端。如果进程是以 shell 命令方式从前台启动,从父进程终止时,shell 就认为命令已经完成,这可以自动使子进程在后台运行。子进程继承了父进程的进程组号,但它拥有自己的进程号,这就保证了此子进程不是进程组长,可以进行下一步创建新会话。
代码语言:javascript代码运行次数:0运行复制 pid_t pid = fork();
if (pid!= 0) {
exit(0);
}
②在子进程中创建新会话:使用setsid()函数在子进程中创建新会话,使子进程完全独立出来,脱离控制。调用setsid()的进程,既是新会话的首进程,也是新进程组的组长进程,并且没有控制终端。如果当前进程原本有一个控制终端,则它失去这个控制终端,成为一个没有控制终端的进程。
代码语言:javascript代码运行次数:0运行复制 setsid();
③再次创建子进程,失去进程组长身份:再次使用fork()函数,退出父进程,使子进程失去进程组长身份,成为普通组员进程。第二次fork的目的是确保守护进程即使打开一个终端设备,也不会自动获得控制终端。
代码语言:javascript代码运行次数:0运行复制pid = fork();
if (pid!= 0) {
exit(0);
}
④改变当前工作目录至根目录下:使用chdir("/")函数改变当前工作目录至根目录下。守护进程一直在后台运行,其工作目录不能被卸载,重新设定当前工作目录可以防止占用可卸载的文件系统。
代码语言:javascript代码运行次数:0运行复制 chdir("/");
⑤重设文件权限掩码:使用umask(0)函数将文件掩码清零,将文件模式创建屏蔽字设置为 0,拒绝某些权限,增加守护进程灵活性。
代码语言:javascript代码运行次数:0运行复制 umask(0);
⑥关闭文件描述符:关闭守护进程从运行它的进程(通常为 shell)继承而来打开的文件描述符。可以使用getdtablesize()函数获取当前进程打开文件的数目,然后遍历关闭所有文件描述符。
9.3守护进程的启动和停止方法
守护进程可以通过systemctl等命令进行管理,也可以使用一些特定的工具和方法进行启动和停止。
①使用nohup命令启动守护进程:
nohup命令可以使进程在后台持续运行,不受终端关闭的影响。例如,nohup command &会在后台启动command命令,并使用nohup使其持续运行。输出会被重定向到nohup.out文件中,如果该文件已经存在,会被追加内容。
②使用start-stop-daemon工具:
start-stop-daemon是一个用于启动或停止系统守护进程的工具。它可以通过指定不同的选项来控制守护进程的启动和停止。例如,可以使用--start选项启动守护进程,使用--stop选项停止守护进程,使用--pidfile选项指定进程的 PID 文件等。
③使用红帽服务管理器启动守护进程:
在红帽 Linux 系统中,可以使用红帽服务管理器来启动守护进程。首先需要编写一个符合红帽服务管理器要求的服务脚本,这个脚本通常包括了指定服务的名称、描述、启动命令、停止命令等信息。在脚本编写完成后,将其保存在指定的目录下,通常是 “/etc/init.d/” 目录中。然后,可以使用红帽服务管理器提供的命令来管理这个服务脚本。通过执行 “service start” 命令,就可以启动对应的守护进程;而执行 “service stop” 命令则可以停止守护进程的运行。此外,还可以使用 “chkconfig” 命令来设置守护进程在系统启动时是否自动运行,以及在哪些运行级别下启动。
案例分析与最近实践
10.1多进程编程实例
在实际编程中,多进程技术可以提高程序的并发性和效率。例如,在处理大量数据时,可以将任务分配给多个进程同时进行处理。以下是一个简单的多进程编程示例:
代码语言:javascript代码运行次数:0运行复制import multiprocessing
def worker():
print("Worker process is running.")
if __name__ == '__main__':
processes = []
for _ in range(5):
p = multiprocessing.Process(target=worker)
processes.append(p)
p.start()
for p in processes:
p.join()
这个示例创建了五个子进程,每个子进程都执行worker函数。通过使用多进程,可以充分利用多核处理器的优势,提高程序的执行速度。
10.2进程监控与自动重启
在 Linux 中,可以使用多种方法实现进程监控与自动重启。一种常见的方法是使用脚本实现,通过定期检查进程是否存在,如果不存在则重新启动。例如,可以使用ps -ef | grep “$1″ | grep -v “grep” | wc –l来获取指定进程的数量,根据进程数量来决定是否需要重新启动进程。这种方法可以通过一个死循环和定时检查来实现,也可以使用crontab来定期执行脚本。
另一种方法是使用exec+fork方式。首先使用fork系统调用创建子进程,在子进程中使用exec函数执行需要自动重启的程序。在父进程中执行wait等待子进程的结束,然后重新创建一个新的子进程。这样可以实现对特定程序的自动监控和重启。以下是一个使用exec+fork方式实现的示例代码:
代码语言:javascript代码运行次数:0运行复制#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
int main(int argc,char**argv)
{
int ret, i, status;
char*child_argv[100]={0};
pid_t pid;
if(argc <2){
fprintf(stderr,"Usage:%s \n", argv[0]);
return -1;
}
for(i = 1; i< argc;++i){
child_argv[i-1]=(char*)malloc(strlen(argv[i])+1);
strncpy(child_argv[i-1], argv[i], strlen(argv[i]));
child_argv[i-1][strlen(argv[i])]='\0';
}
while(1){
pid = fork();
if(pid == -1){
fprintf(stderr,"fork() error.errno:%d error:%s\n", errno, strerror(errno));
break;
}
if(pid ==0){
ret = execv(child_argv[0],(char**)child_argv);
if(ret <0){
fprintf(stderr,"execv ret:%d errno:%d error:%s\n", ret, errno, strerror(errno));
continue;
}
exit(0);
}
if(pid >0){
pid = wait(&status);
fprintf(stdout,"wait return");
}
return 0;
}
}
10.3避免进程泄露和资源耗尽
为了避免进程泄露和资源耗尽,可以采取以下方法:
- 及时清理不再使用的进程:当子进程完成任务后,父进程应及时调用wait或相关函数来获取子进程的退出状态,避免子进程进入僵尸状态。
- 合理管理资源:在程序中,应及时释放不再使用的资源,如文件描述符、内存等。可以使用close函数关闭文件描述符,使用free函数释放动态分配的内存。
- 监控进程状态:可以使用工具如top、ps等定期检查进程的资源使用情况,及时发现资源耗尽的迹象。
- 使用资源限制:可以使用ulimit命令或cgroup控制组来限制进程的资源使用,防止某个进程耗尽系统资源。
例如,可以使用以下代码关闭不再使用的文件描述符:
代码语言:javascript代码运行次数:0运行复制int maxfd = getdtablesize();
int i = 0;
for (; i < maxfd; i++) {
close(i);
}
10.4进程优化技巧
以下是一些优化进程性能的技巧:
- 减少进程创建和销毁的次数:进程的创建和销毁是比较耗时的操作,可以通过复用进程或使用线程来减少进程创建和销毁的次数。
- 合理设置进程优先级:可以使用nice和renice命令调整进程的优先级,使重要的进程获得更多的 CPU 时间。
- 使用写时复制技术:写时复制技术可以减少内存的复制操作,提高进程创建的效率。在 Linux 中,fork函数使用写时复制技术,可以推迟甚至避免拷贝数据。
- 优化进程的 I/O 操作:可以使用异步 I/O 或缓冲 I/O 来减少 I/O 操作的等待时间,提高进程的效率。
- 使用多进程或多线程并行处理:对于可以并行处理的任务,可以使用多进程或多线程来提高程序的执行速度。
例如,可以使用以下代码启动一个进程并设置其优先级:
代码语言:javascript代码运行次数:0运行复制nice -n priority command
其中,priority为一个整数值,范围为 -20 到 19,数值越小表示优先级越高.
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。 原始发表:2025-03-17,如有侵权请联系 cloudcommunity@tencent 删除linux管理函数进程线程本文标签: Linux深入理解进程管理与高效运用
版权声明:本文标题:【Linux】深入理解进程管理与高效运用 内容由热心网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://it.en369.cn/jiaocheng/1748229887a2272565.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论