Linux内核之进程管理与调度(一)
进程是任何多道程序设计的操作系统的基本概念,通常把进程定义为程序执行的示例,作为资源分配的单位。所有的现代操作系统都能够同时运行若干个进程,至少在用户看来是这样的。如果系统只有一个处理器,那么在给定时刻只能有一个程序可以运行,在多处理器系统中才能够做到真正的并行,并行进行的树木取决于处理器数量。Linux作为最流行的现代操作系统,自然是多到程序设计,那么Linux又是如何定义进程,使其能够表示一个程序执行的呢?又是如何对系统中多个进程进行调度,从而实现多任务呢?下面我将基于Linux2.6.11
和2.6.24
两个版本(x86下的32位平台)的Linux内核,对Linux内核的进程管理与调度进行剖析。(之所以选择两个版本,是因为相比于2.6.11的O(1)调度器,2.6.24中实现了大名鼎鼎的CFS调度器)
进程介绍
在本节中,会对进程的概念进行介绍,不涉及实际的Linux源码
概念
进程,在OS教科书中的定义是这样的:进程是程序执行时的一个实例
。它记录了程序执行时CPU时间、CPU寄存器值、占用的内存、使用的文件系统、打开的文件、处理的信号...简单的说,进程可以充分描述程序已经执行到何种程度的数据结构。
进程优先级
实际上,当一个系统中有多个进程时,并非所有进程都具有相同的优先级,除了大家所熟知的进程优先级之外,根据进程实现的功能,还将进程分为不同的种类,以满足不同的需求。进程整体上分为两类:
- 实时进程
- 非实时进程(普通进程)
而实时进程又分为两种,所以我们一共有三种进程
硬实时进程
这类进程有非常严格的时间限制,进程的某些任务必须在指定实现内完成。例如:飞机的飞行控制命令通过计算机处理,那么必须在指定时限内完成,想象一下,如果飞机在着陆,飞行员想要拉起机头,而系统无法保证在指定时限内完成,那大家可以吃席了(逃...)。因此对于硬实时进程,它必须在可保证的时间范围内得到处理,然而这并不意味着所要求的时间范围特别短(即系统必须必须非常快的完成),而是系统保证绝对不会超过任务要求的时间范围,即使在条件不利的情况下。主流Linux
不支持硬实时处理。这是因为Linux是针对吞吐量优化,试图尽快的处理常见情形,很难实现可保证响应时间。
软实时进程
该类进程是硬实时进程的弱化版本,尽快仍然需要快速得到结果,但稍微晚一点儿也不会造成很大的影响。例如对CD的写入操作:CD写入进程接收到的数据必须保持某一速率,因为数据是以连续流的形式写入介质的。如果系统负荷过高,数据流可能会暂时中断,这样就有可能导致写出来的CD不可用,但是也不会导致吃席的情况。不过这种系统会尽量保证这类进程的优先级要高于普通进程。
普通进程
实际上系统中的大多数进程都是普通进程,但是普通进程之间仍然可以根据重要性来分配优先级。一个通用的例子:编译程序和编辑程序。相比于和用户交互的编辑程序,编译程序的优先级更低,这样编辑程序能够更快的响应用户,使得用户体验更好。
优先级划分
Linux系统将所有进程(软实时进程和普通进程)划分了140个优先级(0-139),其中
- 0-99:表示实时进程的优先级,数值越大优先级越高(优先级为99的实时进程具有整个系统最高优先级)
- 100-139:表示普通进程的优先级,数值越小优先级越高(和上面刚好相反)
可能这样的设计很奇怪,但是具体这么设计的原因已经无从考证。
进程生命周期
进程类似于人类,它们被产生,有或多或少有效的生命,可能产生若干个子进程、最终都会死亡。唯一的区别在于进程没有性别(hhh-_-)。
进程并不总是可以立即执行,有时候它必须等待来自外部信号源、不受其控制的事件。例如在文本编辑程序中等待键盘输入。在事件发生之前,进程无法执行。
当调度器在进程之间切换时,必须知道系统中每个进程的状态。将CPU时间分配到无事可做的进程,显然是一种浪费。进程在各个状态之间的转换也同样重要。例如:如果一个进程在等待来自键盘的数据,那么调度器的指责是一旦数据到达,则需要将进程额度状态由等待改为可运行。进程可能有以下几种状态(普通意义而言,不涉及具体操作系统):
- 运行:该进程此刻正在执行
- 等待:进程能够运行,但没有得到许可,因此此时CPU正在执行其他进程
- 睡眠:进程正在睡眠无法运行,因为它在等待一个外部事件。调度器无法在下一次任务切换时选择该进程
- 终止:进程生命周期结束
系统将所有进程保存在一个进程链表中、无论其状态是运行、睡眠还是等待。但睡眠进程会被标注出来,调度器会知道这些进程无法立即执行。睡眠进程会分类到若干队列中(一般来说,进程因为什么睡眠,那么进程就会睡眠在相关的等待队列上),因此进程可以在适当的时间被唤醒。
Linux进程实现
前面介绍了一些进程的基本概念,不设计具体的操作系统。本节主要介绍Linux如何标识一个进程、如何创建进程、如何组织进程之间的关系、如何终止进程等。
标识进程
Linux使用一个被称为进程表描述符
的结构体来标识一个进程,该结构体就是task_struct
,位于sched.h中。子段内容如下(Linux2.6.24):
struct task_struct {
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
void *stack; // 进程内核栈
atomic_t usage; // 进程引用计数,初始化为2
unsigned int flags; /* per process flags, defined below */
unsigned int ptrace; // 记录当前进程的调试状态
int lock_depth; /* BKL lock depth */
#ifdef CONFIG_SMP
#ifdef __ARCH_WANT_UNLOCKED_CTXSW
int oncpu;
#endif
#endif
int prio, static_prio, normal_prio; // 动态优先级,静态优先级以及普通优先级
struct list_head run_list; // 调度相关
const struct sched_class *sched_class; // 进程所属的调度类,2.6.24下一般都是cfs
struct sched_entity se; // 调度实体,2.6.24下的调度器可以调度所有的调度实体,所有包含了调度实体的结构都能被调度
#ifdef CONFIG_PREEMPT_NOTIFIERS
/* list of struct preempt_notifier: */
struct hlist_head preempt_notifiers;
#endif
unsigned short ioprio;
/*
* fpu_counter contains the number of consecutive context switches
* that the FPU is used. If this is over a threshold, the lazy fpu
* saving becomes unlazy to save the trap. This is an unsigned char
* so that after 256 times the counter wraps and the behavior turns
* lazy again; this to deal with bursty apps that only use FPU for
* a short time
*/
unsigned char fpu_counter;
s8 oomkilladj; /* OOM kill score adjustment (bit shift). */
#ifdef CONFIG_BLK_DEV_IO_TRACE
unsigned int btrace_seq;
#endif
unsigned int policy;
cpumask_t cpus_allowed;
unsigned int time_slice;
#if defined(CONFIG_SCHEDSTATS) || defined(CONFIG_TASK_DELAY_ACCT)
struct sched_info sched_info;
#endif
struct list_head tasks;
/*
* ptrace_list/ptrace_children forms the list of my children
* that were stolen by a ptracer.
*/
struct list_head ptrace_children;
struct list_head ptrace_list;
struct mm_struct *mm, *active_mm;
/* task state */
struct linux_binfmt *binfmt;
int exit_state;
int exit_code, exit_signal;
int pdeath_signal; /* The signal sent when the parent dies */
/* ??? */
unsigned int personality;
unsigned did_exec:1;
pid_t pid;
pid_t tgid;
#ifdef CONFIG_CC_STACKPROTECTOR
/* Canary value for the -fstack-protector gcc feature */
unsigned long stack_canary;
#endif
/*
* pointers to (original) parent process, youngest child, younger sibling,
* older sibling, respectively. (p->father can be replaced with
* p->parent->pid)
*/
struct task_struct *real_parent; /* real parent process (when being debugged) */
struct task_struct *parent; /* parent process */
/*
* children/sibling forms the list of my children plus the
* tasks I'm ptracing.
*/
struct list_head children; /* list of my children */
struct list_head sibling; /* linkage in my parent's children list */
struct task_struct *group_leader; /* threadgroup leader */
/* PID/PID hash table linkage. */
struct pid_link pids[PIDTYPE_MAX];
struct list_head thread_group;
struct completion *vfork_done; /* for vfork() */
int __user *set_child_tid; /* CLONE_CHILD_SETTID */
int __user *clear_child_tid; /* CLONE_CHILD_CLEARTID */
unsigned int rt_priority;
cputime_t utime, stime, utimescaled, stimescaled;
cputime_t gtime;
cputime_t prev_utime, prev_stime;
unsigned long nvcsw, nivcsw; /* context switch counts */
struct timespec start_time; /* monotonic time */
struct timespec real_start_time; /* boot based time */
/* mm fault and swap info: this can arguably be seen as either mm-specific or thread-specific */
unsigned long min_flt, maj_flt;
cputime_t it_prof_expires, it_virt_expires;
unsigned long long it_sched_expires;
struct list_head cpu_timers[3];
/* process credentials */
uid_t uid,euid,suid,fsuid;
gid_t gid,egid,sgid,fsgid;
struct group_info *group_info;
kernel_cap_t cap_effective, cap_inheritable, cap_permitted;
unsigned keep_capabilities:1;
struct user_struct *user;
#ifdef CONFIG_KEYS
struct key *request_key_auth; /* assumed request_key authority */
struct key *thread_keyring; /* keyring private to this thread */
unsigned char jit_keyring; /* default keyring to attach requested keys to */
#endif
char comm[TASK_COMM_LEN]; /* executable name excluding path
- access with [gs]et_task_comm (which lock
it with task_lock())
- initialized normally by flush_old_exec */
/* file system info */
int link_count, total_link_count;
#ifdef CONFIG_SYSVIPC
/* ipc stuff */
struct sysv_sem sysvsem;
#endif
/* CPU-specific state of this task */
struct thread_struct thread;
/* filesystem information */
struct fs_struct *fs;
/* open file information */
struct files_struct *files;
/* namespaces */
struct nsproxy *nsproxy;
/* signal handlers */
struct signal_struct *signal;
struct sighand_struct *sighand;
sigset_t blocked, real_blocked;
sigset_t saved_sigmask; /* To be restored with TIF_RESTORE_SIGMASK */
struct sigpending pending;
unsigned long sas_ss_sp;
size_t sas_ss_size;
int (*notifier)(void *priv);
void *notifier_data;
sigset_t *notifier_mask;
#ifdef CONFIG_SECURITY
void *security;
#endif
struct audit_context *audit_context;
seccomp_t seccomp;
/* Thread group tracking */
u32 parent_exec_id;
u32 self_exec_id;
/* Protection of (de-)allocation: mm, files, fs, tty, keyrings */
spinlock_t alloc_lock;
/* Protection of the PI data structures: */
spinlock_t pi_lock;
#ifdef CONFIG_RT_MUTEXES
/* PI waiters blocked on a rt_mutex held by this task */
struct plist_head pi_waiters;
/* Deadlock detection and priority inheritance handling */
struct rt_mutex_waiter *pi_blocked_on;
#endif
#ifdef CONFIG_DEBUG_MUTEXES
/* mutex deadlock detection */
struct mutex_waiter *blocked_on;
#endif
#ifdef CONFIG_TRACE_IRQFLAGS
unsigned int irq_events;
int hardirqs_enabled;
unsigned long hardirq_enable_ip;
unsigned int hardirq_enable_event;
unsigned long hardirq_disable_ip;
unsigned int hardirq_disable_event;
int softirqs_enabled;
unsigned long softirq_disable_ip;
unsigned int softirq_disable_event;
unsigned long softirq_enable_ip;
unsigned int softirq_enable_event;
int hardirq_context;
int softirq_context;
#endif
#ifdef CONFIG_LOCKDEP
# define MAX_LOCK_DEPTH 30UL
u64 curr_chain_key;
int lockdep_depth;
struct held_lock held_locks[MAX_LOCK_DEPTH];
unsigned int lockdep_recursion;
#endif
/* journalling filesystem info */
void *journal_info;
/* stacked block device info */
struct bio *bio_list, **bio_tail;
/* VM state */
struct reclaim_state *reclaim_state;
struct backing_dev_info *backing_dev_info;
struct io_context *io_context;
unsigned long ptrace_message;
siginfo_t *last_siginfo; /* For ptrace use. */
#ifdef CONFIG_TASK_XACCT
/* i/o counters(bytes read/written, #syscalls */
u64 rchar, wchar, syscr, syscw;
#endif
struct task_io_accounting ioac;
#if defined(CONFIG_TASK_XACCT)
u64 acct_rss_mem1; /* accumulated rss usage */
u64 acct_vm_mem1; /* accumulated virtual memory usage */
cputime_t acct_stimexpd;/* stime since last update */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *mempolicy;
short il_next;
#endif
#ifdef CONFIG_CPUSETS
nodemask_t mems_allowed;
int cpuset_mems_generation;
int cpuset_mem_spread_rotor;
#endif
#ifdef CONFIG_CGROUPS
/* Control Group info protected by css_set_lock */
struct css_set *cgroups;
/* cg_list protected by css_set_lock and tsk->alloc_lock */
struct list_head cg_list;
#endif
#ifdef CONFIG_FUTEX
struct robust_list_head __user *robust_list;
#ifdef CONFIG_COMPAT
struct compat_robust_list_head __user *compat_robust_list;
#endif
struct list_head pi_state_list;
struct futex_pi_state *pi_state_cache;
#endif
atomic_t fs_excl; /* holding fs exclusive resources */
struct rcu_head rcu;
/*
* cache last used pipe for splice
*/
struct pipe_inode_info *splice_pipe;
#ifdef CONFIG_TASK_DELAY_ACCT
struct task_delay_info *delays;
#endif
#ifdef CONFIG_FAULT_INJECTION
int make_it_fail;
#endif
struct prop_local_single dirties;
};
可以看到,task_struct
包含的字段非常之多,这些字段把进程与各个内核子系统联系起来,等使用到相应字段时,在进行介绍,这里只是简单的罗列。该结构的内容可以分为不同的部分,每个部分表示进程的一个特定方面
- 状态和执行信息 如信号、程序文件使用的二进制格式、进程的PID、到父进程以及其他进程的指针、优先级以及程序执行有关的时间信息
- 分配给进程的虚拟内存信息
- 进程的身份凭据,如用户ID、组ID以及权限等。可以使用系统调用查询/修改这些数据
- 当前进程使用的文件、进程处理的所有文件的文件系统信息
- 该进程特定于CPU的运行时间数据
- 该进程使用的所有信号处理程序,用于响应到来的信号
- ...…
task_struct
许多成员并非简单类型变量,而是指向其他数据结构的指针,具体信息在用到时会进行介绍。
进程状态
在前面一节介绍了通用操作系统的状态。在本节将对Linux2.6.24
版本的进程状态进行介绍
可运行状态(TASK_RUNNING)
此状态的进程要么在CPU上运行,要么准备执行(可以被调度程序选中执行)
可中断睡眠状态(TASK_INTERRUPTIBLE)
进程被挂起,直到某个条件变为真。产生一个中断、释放进程等待的资源、或者传递一个信号都可以作为唤醒进程的条件(把进程状态修改为TASK_RUNNING)
不可中断睡眠状态(TASK_UNINTERRUPTIBLE)
与可中断睡眠类似,但是此状态下不响应信号。这种状态很少用到,因为如果由于一些原因导致对此类进程的唤醒操作没有发生,那么该进程永远无法醒过来,也无法被终止(不接受任何信号,包括KILL)。
可终止不可中断睡眠状态(TASK_KILLABLE)
与不可中断睡眠类似,但是该状态下可以响应致命信号,因此可以被终止。
暂停状态(TASK_STOPPED)
进程的执行被暂停,当进程接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU信号时进入此状态。
跟踪状态(TASK_TRACED)
进程的执行由debugger程序暂停。当一个进程被另一个进程监控时任何信号都可以把该进程状态置为TASK_TRACED
僵尸状态(TASK_ZOMBIE)
进程的执行被终止、但是父进程还没有发布wait
或wait4
系统调用来返回有关死亡进程的信息。发布wait
系统调用前、内核不能丢弃包含在死亡进程中的数据,因为父进程可能还需要这些信息
死亡状态(EXIT_DEAD)
最终状态:当父进程发送了wait
或wait4
系统调用后,进程由系统删除。为了防止其他执行线程在同一个进程上也执行wait
类系统调用(这是一种竞争条件),因此把进程状态修改为该状态。
其中TASK_ZOMBIE
和EXIT_DEAD
状态既可用于task_struct.state
字段,也可以用于task_struct.exit_state
字段
进程资源限制
Linux提供了资源限制机制,对进程使用的系统资源加以某些限制。该机制利用的task_struct.signal.rlim
字段实现,其中signal子段是signal_struct
类型,和进程信号相关,这里不多介绍,rlim字段是resource.rlimit
类型,结构如下(Linux2.6.24):
struct rlimit {
unsigned long rlim_cur;
unsigned long rlim_max
}
上述定义非常通用,可以用于多种不同的资源限制,实际上也确实如此,task_struct.signal.rlim
是一个数组,述祖的每一项限制了一种资源。
- rlim_cur是进程当前资源限制,是一种软限制
- rlim_max是该限制的最大容许值,是硬限制
可以使用系统调用setrlimit来修改限制,但是不能超出rlim_max执行的值。具体的资源限制报错最大CPU时间、最大文件长度、最大用户栈长度、打开文件最大数目....感兴趣可以查看Linux代码
idle进程(注意,这里指的是0号进程,idle进程或者swagger进程)的限制在系统启动时即生效,定义在include/asm/generic/resource.h
中的INIT_RLIMITS
。
命名空间
命名空间为更加轻量级的虚拟化提供了支持,使得我们可以从不同的方面查看运行系统的全局属性。传统上来说,Linux中许多资源都是全局管理的。例如系统中所有进程都通过PID来标识,那么这就意味着内核必须管理一个全局PID列表,来保证PID的唯一性,并且系统中所有调用者通过uname系统调用返回的系统相关信息(系统名称、版本等信息)也是相同的。
全局ID使得内核可以有选择的允许或者拒绝某些特权,比如:UID为0的root用户几乎可以做任何事情,但是其他非root的用户ID则会受到限制,比如说UID为n的用户不能够杀死UID为m用户的进程,这是符合常识的。因为无论是UID还是PID都是全局唯一的,所以内核可以很轻松的做到这些事情。
另外,虽然内核能够阻止n用户杀死m用户的进程,但是却不能阻止他们看到彼此,也就是说:同一个系统上的用户能够看到其他用户的进程、资源等..但是却不能轻易的操作其它用户的资源,这正是我们日常见到的那样。这看起来也并没有什么问题,因为我们日常在自己的电脑上都是这么用的。
但是在有些情况下,这并不是我们想要的。想象一下,一个提供主机服务的供应商打算向用户提供Linux计算机的全部访问权限,也包括root权限。按照前面的来说,供应商需要为每个用户都准备一台计算机,因为传统的计算机资源都是唯一的,当然页包括root资源,一台机器上当然不能有两个root用户。并且此时用户也并不希望同意计算机上的其他用户看到自己的资源(即使其他人可能无法操作)。如果为每个用户都准备一台计算机,那代价也太高了。
有什么方法来解决这个事情吗?当然有,那就是使用KVM或VMWare提供的虚拟化环境,但是这种方式太重的,计算机的各个用户都需要一个独立的内核,以及一份完全安装好的配套的用户层应用,并且如果你稍微了解一个传统虚拟化的实现就可以发现,计算机相当一部分资源都消耗在虚拟化上,这就导致一台计算机实际上运行不了太多的虚拟机,并且虚拟机的资源分配做的也不是很好,这对于厂商来说也是不够满意的。
说到这里,就要到我们的主角登场了,Linux的命名空间提供了全新的解决方案,并且所需要的资源也比较少:这就是容器
。和传统的虚拟化不同,命名空间只需要一个内核在一台物理计算机上运作,将前面说到的所有全局资源通过不同的命名空间抽象起来。这样就可以将一组进程放到容器中,容器之间彼此隔离,隔离可以使容器之间互不可见,每个容器都认为自己是一台真正的计算机。当然也可以允许容器进行一定的操作,来降低容器之间的分隔。例如:不同的容器之间可以共享部分文件系统,这样它们在共享部分的修改则互相可见。
本质上,命名空间建立了系统的不同视图,此前的全局资源都必须包装到容器数据结构中,此时只有<资源,包含资源的命名空间>
构成的二元组是全局唯一的。比如用户的UID,在容器内部,用户的UID仍然是唯一的,但是在容器外部无法保证唯一,比如每个容器都有一个UID为0的root用户。
层次化命名空间
命名空间可以组织为层次化,如下图所示,一个命名空间是父命名空间,衍生了两个子命名空间。我们假定要将命名空间用于支持容器化,那么每个容器必须看起来像是一台单独的计算机,因此其中每个都有自身的init进程(PID为1),其他进程的PID递增分配。两个子命名空间都有各自PID为1的init进程,可以看到在多命名空间下PID号不是唯一的。
虽然子容器(子命名空间)不了解系统中的其他容器,但是父容器却知道子容器的存在,也可以看到其中执行的所有进程。如图所示,子容器的进程可以映射到父容器中,PID为4-9。因此尽管系统上有9个进程,但需要15个PID来标识(每个进程可能需要多个PID,在子容器和父容器中具有不同的PID)
非层次化命名空间
当然,也并非所有命名空间都是非层次化的,如果命名空间中包含比较简单的量,那么它也可以是非层次化的,比如UTS
命名空间,在这种情况下,父子命名空间之间没有关系。UTS也就是Unix Timesharing System
的简称,该命名空间下包含了运行内核的名称、版本、底层体系结构类型等信息。通过UTS命名空间,就实现了同一个系统下不同命名空间中可能会看到不同的内核版本等信息。
命名空间创建
Linux对简单形式的命名空间的支持已经很久了,主要是chroot系统调用。该方法可以将进程限制到文件系统的某一部分,可以看作一种简单的命名空间机制,但真正的命名空间能够控制的功能远远超过文件系统(CPU、内存、网络等..)。新的命名空间可以使用下面两种方法创建:
- 在fork或clone系统调用创建新进程时,有特定的选项可以控制子进程是否与父进程共享命名空间,或者是建立单独的命名空间
- unshare系统调用将进程的某些部分从父进程分离,其中也包括命名空间。
在进程使用以上两种手段建立的单独的命名空间后,从该进程的角度来看,改变全局属性不会传播到父进程的命名空间,同样的父进程的修改也不会传播到子进程。但是对于文件系统,情况会比较复杂,强大的共享机制带来了大量的可能性,但这不是本文的重点,因此让我们忽略这一部分。
实现
想象一下,在Linux下实现一个命名空间,需要什么呢?主要有以下两个
- 如果一个子系统需要实现命名空间,那么它需要一个特定的命名空间结构,该结构能将之前该子系统的所有全局资源包装到命名空间中
- 上面一步只是实现了命名空间,单独的命名空间是没有意义的,因此还需要有一种机制把进程关联到所属的各个命名空间中。
如果一个子系统实现了命名空间,那么该子系统此前的全局属性都必须封装到命名空间中,并且每个进程关联到一个选定的命名空间。Linux是如何实现的呢?Linux通过
nsproxy
结构体,将目前版本下的6个支持命名空间的子系统数据结构汇集在一起,然后将nsproxy
作为进程结构体的一个字段。这样一来,进程就能够感知到内核目前支持命名空间的子系统了。
nsproxy
结构体如下所示
struct nsproxy {
atomic_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns;
struct user_namespace *user_ns;
struct net *net_ns;
};
count
: 引用计数器,记录了引用该nsproxy的进程个数量uts_ns
:UTS命名空间ipc_ns
:Linux IPC命名空间(进程间通信)mnt_ns
:已装载的文件系统视图pid_ns
:进程ID命名空间user_ns
: 用户命名空间,目前版本的内核中只是保存了用于限制用户资源使用的信息net_ns
:网络相关的命名空间参数
本文会对UTS命名空间
、用户命名空间
以及PID命名空间
进行介绍,其他的命名空间以后介绍到相应内核子系统时再分析。
前面说过,使用fork或clone创建新进程时可以通过标志创建新命名空间,每个命名空间都有相应的标志,这些标志如下:
#define CLONE_NEWUTS 0x04000000 /\* New utsname group? */
#define CLONE_NEWIPC 0x08000000 /\* New ipcs */
#define CLONE_NEWUSER 0x10000000 /\* New user namespace */
#define CLONE_NEWPID 0x20000000 /\* New pid namespace */
#define CLONE_NEWNET 0x40000000 /\* New network namespace */
回顾前面task_struct
结构中的nsproxy
结构体,可以发现用到了指针,这样一来,当多个进程共享一组子命名空间时,修改了给定的命名空间,那么对所有该命名空间的进程都是可见的。
对Linux命名空间的支持必须在编译时启用,而且需要逐一指定需要支持的命名空间。但是如果没有启用命名空间或者没有指定某个子系统的命名空间,Linux会将进程关联到一个默认命名空间。例如:如果没有启动命名空间,那么Linux所有的进程都属于一个默认命名空间,因此此前所有的全局属性此时仍然是全局的。这样做的好处就是无论内核编译时是否支持命名空间,内核代码都不用改动。
init_nsproxy
定义了初始全局命名空间,位于kernel/nsproxy.h
下
struct nsproxy init_nsproxy = INIT_NSPROXY(init_nsproxy);
// 位于include/linux/init_task.h
#define INIT_NSPROXY(nsproxy) { \
.pid_ns = &init_pid_ns, \
.count = ATOMIC_INIT(1), \
.uts_ns = &init_uts_ns, \
.mnt_ns = NULL, \
INIT_NET_NS(net_ns) \
INIT_IPC_NS(ipc_ns) \
.user_ns = &init_user_ns, \
}
UTS命名空间
UTS命名空间基本不需要什么复杂的结构来表示,因为该命名空间本身包含的信息就比较简单,也不需要层次化结构。该命名空间管理的信息如下:
struct uts_namespace {
struct kref kref;
struct new_utsname name;
};
struct new_utsname {
char sysname[65];
char nodename[65];
char release[65];
char version[65];
char machine[65];
char domainname[65];
};
struct uts_namespace init_uts_ns = {
.kref = {
.refcount = ATOMIC_INIT(2),
},
.name = {
.sysname = UTS_SYSNAME,
.nodename = UTS_NODENAME,
.release = UTS_RELEASE,
.version = UTS_VERSION,
.machine = UTS_MACHINE,
.domainname = UTS_DOMAINNAME,
},
};
kref
:引用计数器,记录内核有多少地方使用了该实例new_utsname
:包含了UTS命名空间管理的属性,可以使用uname获取这些信息,初始设置如上所示
那么要如何创建一个新的UTS命名空间呢?该功能由copy_utsname函数实现,在fork系统调用并且传递了CLONE_NEWUTS
标识时,则调用该函数,该函数主要作用是生成父进程uts_namespace的一个副本然后赋值给子进程,这样以来,父进程的修改就无法反映到子进程,子进程也不会影响父进程了。
struct uts_namespace *copy_utsname(unsigned long flags, struct uts_namespace *old_ns)
{
struct uts_namespace *new_ns;
BUG_ON(!old_ns);
// 将old_ns的引用计数器+1
get_uts_ns(old_ns);
// 如果没有设置CLONE_NEWUTS标志,那么父子进程共享该命名空间
if (!(flags & CLONE_NEWUTS))
return old_ns;
// 拷贝父进程命名空间的副本
new_ns = clone_uts_ns(old_ns);
// old_ns的引用计数器-1
put_uts_ns(old_ns);
return new_ns;
}
static struct uts_namespace *clone_uts_ns(struct uts_namespace *old_ns)
{
struct uts_namespace *ns;
// 申请内存存放新的uts命名空间结构
ns = kmalloc(sizeof(struct uts_namespace), GFP_KERNEL);
if (!ns)
return ERR_PTR(-ENOMEM);
// 信号量-1,临界区
down_read(&uts_sem);
// 拷贝符进程uts_namespace
memcpy(&ns->name, &old_ns->name, sizeof(ns->name));
// 信号量+1
up_read(&uts_sem);
kref_init(&ns->kref);
return ns;
}
用户命名空间
用户命名空间的管理类似于UTS,也不需要层次化,在要求创建新的用户命名空间时,生成父进程的一份拷贝,并关联到当前进程的nsproxy实例,用户命名空间结构体如下:
struct user_namespace {
struct kref kref;
struct hlist_head uidhash_table[UIDHASH_SZ];
struct user_struct *root_user;
};
struct user_struct {
atomic_t __count; /* reference count */
atomic_t processes; /* How many processes does this user have? */
atomic_t files; /* How many open files does this user have? */
atomic_t sigpending; /* How many pending signals does this user have? */
#ifdef CONFIG_INOTIFY_USER
atomic_t inotify_watches; /* How many inotify watches does this user have? */
atomic_t inotify_devs; /* How many inotify devs does this user have opened? */
#endif
#ifdef CONFIG_POSIX_MQUEUE
/* protected by mq_lock */
unsigned long mq_bytes; /* How many bytes can be allocated to mqueue? */
#endif
unsigned long locked_shm; /* How many pages of mlocked shm ? */
#ifdef CONFIG_KEYS
struct key *uid_keyring; /* UID specific keyring */
struct key *session_keyring; /* UID's default session keyring */
#endif
/* Hash table maintenance information */
struct hlist_node uidhash_node;
uid_t uid;
#ifdef CONFIG_FAIR_USER_SCHED
struct task_group *tg;
#ifdef CONFIG_SYSFS
struct kset kset;
struct subsys_attribute user_attr;
struct work_struct work;
#endif
#endif
};
kref
:引用计数器hlist_head
:hash表头,可以通过hash表快速访问指定的user_struct实例,可以看到user_struct中包含了hlist_node
字段user_struct
:这里不必对该结构的内容深究,只需要知道该结构维护了一些统计信息,用于记录该用户的资源使用信息
每个用户命名空间在对属于该命名空间的用户资源使用进行统计时与其他命名空间完全无关,对root用户的统计也是如此。因为在创建新用户命名空间时,为新的命名空间创建了当前用户和root用户的user_struct实例:
static struct user_namespace *clone_user_ns(struct user_namespace *old_ns)
{
struct user_namespace *ns;
struct user_struct *new_user;
int n;
// 申请内存
ns = kmalloc(sizeof(struct user_namespace), GFP_KERNEL);
if (!ns)
return ERR_PTR(-ENOMEM);
// 初始化引用计数器为1
kref_init(&ns->kref);
// 初始化hash表
for (n = 0; n < UIDHASH_SZ; ++n)
INIT_HLIST_HEAD(ns->uidhash_table + n);
// 为新的命名空间创建新的root用户
ns->root_user = alloc_uid(ns, 0);
if (!ns->root_user) {
kfree(ns);
return ERR_PTR(-ENOMEM);
}
// 为新的命名空间创建新的当前用户
new_user = alloc_uid(ns, current->uid);
if (!new_user) {
free_uid(ns->root_user);
kfree(ns);
return ERR_PTR(-ENOMEM);
}
// 将task_struct.user字段指向新的当前用户实例
switch_uid(new_user);
return ns;
}
alloc_uid
是一个辅助函数,给定一个用户命名空间和一个uid,判断当前命名空间内是否包含该uid,如果包含则返回该uid对应的user_struct,如果不包含则创建新的user_struct实例并插入到当前命名空间中。
如果内核编译时没有启用用户命名空间,那么复制用户命名空间其实是一个空操作,即整体系统只有一个命名空间,那就是默认命名空间。
进程ID
Linux进程总会分配一个ID用于在其进程命名空间中唯一的标识它们。该号码被称为进程ID号,简称PID。用fork或clone产生的每个进程都由内核自动地分配了一个新的PID。
进程ID
每个进程除了PID这个标识之外,还有其他的ID。
- 处于某个线程组中的所有进程都用相同的线程组ID(TGID),并且等于线程组领头进程的PID,对于进程而言,它的TGID等于PID。另外线程组中每个线程结构题的
group_leader
字段指向线程组的领头进程 - 若干个独立进程也可以合并成一个进程组(使用setpgrp系统调用),一个进程组中,所有进程的pgrp属性都是相通的,就是进程组组长的PID。进程组的存在大大简化了向一个组中所有成员发送信号的操作。例如:shell脚本中管道连接的多个进程就属于同一个进程组
- 几个进程组可以合并成一个会话,会话中所有进程都具有相同的会话ID,保存在
task_struct
的session
字段中。可以使用setsid系统调用设置sid。
上面说的会话ID和进程组ID并不是直接包含在task_struct中,而是包含在task_struct.signal
中,用于信号处理。而PID和TGID是直接保存在task_struct
结构中的
在没有命名空间时,进程就有好几种不同的ID,添加了命名空间后,进程ID的管理就更加复杂了。PID命名空间按照层次组织。在建立一个新的PID命名空间时,新命名空间中所有的PID对父命名空间都是可见的,但子命名空间无法看到父命名空间的PID。这意味着一个进程可能有多个PID,因为凡是可以看到该进程的命名空间,都会给该进程分配一个PID。因此我们对这些ID进行区分
- 全局ID: 内核本身和初始化PID命名空间中的唯一ID号,在系统启动过程中开始的init进程即属于初始命名空间。对每个ID类型,都有给定的全局ID,在整个系统内唯一
- 局部ID: 属于某个特定的命名空间,全局不唯一,但是命名空间内部唯一
PID管理
除了上一节说到的task_struct中的两个字段用来管理PID、TGID之外,内核还需要找一个办法来管理所有命名空间内部的局部量,以及其他ID(TID、SID)。这需要几个互相连接的数据结构,以及一些辅助函数
数据结构
在本节中,我将使用ID来代指提到的任何进程ID,在必要时,会明确的说明ID类型(例如TGID、PID等)。Linux有一个被称为PID分配器
的小型子系统。该子系统用户加速新ID的分配。此外内核需要提供辅助函数,从而实现通过ID
及类型
查找对应进程的task_struct
功能、以及将ID的内核表示形式和用户空间可见的数值进行转换功能。
PID命名空间
在介绍ID本身需要的数据结构之前,先介绍一下PID命名空间,结构如下:
struct pid_namespace {
struct kref kref;
struct pidmap pidmap[PIDMAP_ENTRIES];
int last_pid;
struct task_struct *child_reaper;
struct kmem_cache *pid_cachep;
int level;
struct pid_namespace *parent;
#ifdef CONFIG_PROC_FS
struct vfsmount *proc_mnt;
#endif
};
kref
: 引用计数器pidmap & last_pid
: 用于生成命名空间内唯一连续PIDchild_reaper
: 每个PID命名空间内都有一个进程,发挥的作用类似于全局的init进程(PID为1),该进程的一个作用就是对孤儿进程调用wait4系统调用,该字段就指向命名空间内部的init进程pid_cachep
: slab高速缓存,用于快速分配pid结构体(后面会说到)level & parent
:前面说过,PID命名空间是层次化的,因此level表明了当前命名空间的层次、parent指向当前命名空间的父命名空间。
数据结构
PID的管理主要由一下数据结构完成
struct upid {
/* Try to keep pid_chain in the same cacheline as nr for find_pid */
int nr; // ID的数值
struct pid_namespace *ns; // 指向该ID所属的命名空间
struct hlist_node pid_chain; // 所有的upid实例都保存在散列表中
};
struct pid {
atomic_t count; // 引用计数器
/* lists of tasks that use this pid */
struct hlist_head tasks[PIDTYPE_MAX]; // 一个数组,没个数组项都是一个散列表头,对应一种PID类型
struct rcu_head rcu; // rcu机制使用
int level; // 该PID所属命名空间的层次
struct upid numbers[1]; // upid实例的数组
};
struct task_struct {
....
struct pid_link pids[PIDTYPE_MAX]; // 数组,每个元素指向一种类型的ID的pid结构
....
}
struct pid_link {
struct hlist_node node; // hash节点
struct pid *pid; // 指向pid结构
}
static struct hlist_head *pid_hash // 内核变量,由pidhash_init初始化成一个数组,从而组成一个hash散列表
上述结构设计到的结构比较多,如果用语言介绍的话,很容易就迷糊了,因此我们通过下面一张图来说明这些结构之间的关系:
通过图我们很容易看到进程、PID命名空间、pid结构、upid之间的关系。可以看到,一个进程对应一个pid实例,pid实例中并不包含pid数值,pid结构中包含了所有能看到该进程的命名空间对应的upid,每个upid实例保存了进程在指定PID命名空间中的PID值,并且所有的upid都保存在名为pid_hash
的hash表中,通过hash表能够快速的找到指定的upid,从而找到pid,进而找到task。到这里你可能有疑惑,upid命名没有指向pid的指针,怎么通过upid找到pid呢?upid虽然没有指向pid的指针,但是upid包含在pid中,Linux通过container_of机制,根据upid就能够找到指定的pid实例。
函数
前面一小节对PID管理的各个数据结构以及它们之间的关系进行了介绍,相信它们之间的关系你已经有所理解,本节主要介绍内核提供的用来操作和扫描上述数据结构的辅助函数,实际上内核需要完成两个任务
- 给定
<局部数字ID,命名空间>
,找到对应的task_struct
- 给定
<task_struct, ID类型, 命名空间
,找到对应的数字ID
实际上通过上图,应该不难理解如何实现这样的功能,下面就介绍内核用来实现这两个任务的辅助函数
任务1
通过上图可以很容易理解,通过给定的task_struct
和ID类型
, 查找task_struct指定类型的pids元素,可以找到对应的pid实例,通过pid实例和pid_namespace实例,很容易可以找到对应的upid,从而找到对应的pid数字
内核有以下几个函数
pid_t pid_nr_ns(struct pid *pid, struct pid_namespace *ns): 给定pid实例和ns实例,找到对应的PID数值
static inline pid_t pid_vnr(struct pid *pid) {}: 返回该pid实例所属命名空间看到的PID数值
static inline pid_t pid_nr(struct pid *pid){}: 返回从init进程所在的全局命名空间所看到的PID,这里说的init进程时系统初始化过程中启动的属于全局命名空间的init进程,并且子命名空间内的
任务2
现在来看看如何根据给定的进程局部PID和所属的命名空间,确定一个pid实例。根据图可以看到,所有的upid都在一个hash表中,那么我们可以根据<局部PID,命名空间>
,在散列表中找到指定的upid实例,通过upid实例,再通过内核的container_of机制,就能够轻松的找到该upid所在的pid实例了,通过pid实例,进而还能找到pid所属的进程结构体task_struct
。
生成唯一PID
除了管理PID之外,内核还需要提供生成唯一PID的功能。此时可以忽略各种不同类别的ID之间的区别,因为按照Unix概念来说,只需要为PID生成唯一的数值,其他ID都可以派生自PID。
通过上图可以发现,每个PID命名空间都有两个和PID分配相关的字段,为了跟踪指定命名空间中已经分配和仍然可用的PID,内核使用一个大的位图,其中每个PID由一个bit标识。PID的值可通过对应bit位在位图中的位置计算而来。因此,分配一个空闲的PID,本质上就是寻找位图中第一个值为0的bit,然后将该bit置1。而释放一个PID就是将对应的bit位置0。申请和释放PID函数如下:
// 在给定命名空间申请新的PID
static int alloc_pidmap(struct pid_namespace *pid_ns){}
// 释放给定命名空间的PID
static fastcall void free_pidmap(struct pid_namespace *pid_ns, int pid){}
在建立一个新进程时,进程可能在多个命名空间是可见的,因此对于每一个可以看到该进程的命名空间,都需要为该进程生成一个局部PID,该操作在alloc_pid中实现:
struct pid *alloc_pid(struct pid_namespace *ns)
{
struct pid *pid;
enum pid_type type;
int i, nr;
struct pid_namespace *tmp;
struct upid *upid;
// 从pid_namespace实例的cache中申请一个pid实例
pid = kmem_cache_alloc(ns->pid_cachep, GFP_KERNEL);
if (!pid)
goto out;
tmp = ns;
// 对于当前命名空间的上级空间,该进程都是可见的,因此都需要分配局部PID
for (i = ns->level; i >= 0; i--) {
// 从PID位图中申请局部PID
nr = alloc_pidmap(tmp);
if (nr < 0)
goto out_free;
// 生成对应的upid实例保存到pid.numbers数组中
pid->numbers[i].nr = nr;
pid->numbers[i].ns = tmp;
tmp = tmp->parent;
}
// 增加当前命名空间的引用计数
get_pid_ns(ns);
pid->level = ns->level;
// 增加引用计数
atomic_set(&pid->count, 1);
// 初始化pid实例字段
for (type = 0; type < PIDTYPE_MAX; ++type)
INIT_HLIST_HEAD(&pid->tasks[type]);
// 自旋锁 + 关中断(多处理器下)
spin_lock_irq(&pidmap_lock);
for (i = ns->level; i >= 0; i--) {
upid = &pid->numbers[i];
// 将新pid实例下的每个upid实例添加到pid_hash中
hlist_add_head_rcu(&upid->pid_chain,
&pid_hash[pid_hashfn(upid->nr, upid->ns)]);
}
// 解锁 + 开中断
spin_unlock_irq(&pidmap_lock);
out:
return pid;
out_free:
for (i++; i <= ns->level; i++)
free_pidmap(pid->numbers[i].ns, pid->numbers[i].nr);
kmem_cache_free(ns->pid_cachep, pid);
pid = NULL;
goto out;
}
进程关系
在Linux中,进程关系有下面这样的规则
- 如果进程A fork生成进程B,那么进程A为进程B的父进程,进程B是进程A的子进程。
- 如果进程A再次fork生成进程C,那么进程C是进程A的子进程,是进程B的兄弟进程。
回顾本节开始的进程结构task_struct
,可以发现有以下两个字段用于表示进程关系:
struct task_struct {
struct list_head children;
struct list_head sibling;
}
children
:链表表头,该链表保存该进程所有的子进程sibling
:属于同一个父进程的兄弟进程通过该链表连接,其中第一个子进程的sibling.prev
指向父进程,最后一个子进程的sibling.next
指向父进程。
进程相关系统调用
在本节中,将着重讨论fork和exec系统调用的实现。通常这些调用不是由应用程序直接调用的,而是被标准库包装后提供给应用程序使用。Linux从用户态切换到核心态的方法在不同的硬件体系上有所区别,后面会介绍x86下的切换细节,但是这部分不是本文的重点,因此不做介绍,目前只需要知道执行系统调用时程序会进入内核态执行即可。
进程复制
传统Unix中用于复制进程的系统调用时fork。Linux复制进程的方式不止这一个,一共有三个,分别是
fork
:建立一个父进程的完整副本,然后作为子进程执行,为了减少工作量,加快速度,Linux使用了写时复制技术,后面会简单介绍。vfork
:类似于fork,但是并不创建父进程的副本,而是由父子进程共享数据。这节省了大量的CPU时间。vfork一般用于子进程形成后立刻执行execve系统调用加载程序的情形。在子进程退出或开始新程序之前,内核保证父进程处于阻塞状态。在fork采用写时复制之后,vfork相比于fork没有太多的优势,一般不使用clone
:产生线程
写时复制(COW)
内核使用了写时复制技术,以防止在fork执行时内核将父进程所有数据都拷贝一份给子进程。该技术基于以下原理:进程通常只使用了其内存页的一小部分。传统Unix在调用fork时对父进程的每个内存页,都为子进程创建一个相同的副本,这会造成很不好的后果:
- 使用了大量内存
- 复制操作耗费很长时间
并且如果子进程在产生后使用exec系统调用立即加在新程序,那么负面效应会更加严重。这就意味着fork中的复制是多余的,因为进程地址空间会重新初始化,复制的数据不再需要了。
内核使用技巧避免了这种问题,并不复制进程整个地址空间,而是只复制页表,这样就建立了虚拟地址和物理内存页之间的关系。在复制页表后,父子进程不允许修改彼此的页(除了明确指定共享的页之外)。内核通过将两个进程的页表标记为只读访问来实现。
如果两个进程只能读取其内存页,那么二者之间数据共享是可以的,因为不存在数据共享,只要一个进程试图向内存页写入,那么由于页表只读,此时硬件会产生一个页错误异常,内核会捕获到该异常,内核会进行判断,如果发现是写时复制页,那么内核会创建该页专用于当前进程的副本,这时进程就能够进行正常的写操作了。COW技术及可能的延迟了内存页的复制,由于进程执行的局部性特点,许多页其实不需要复制,这节省了大量CPU时间。
执行系统调用
fork
、vfork
以及clone
系统调用的入口点分别是sys_fork
、sys_vfork
以及sys_clone
,具体的定义依赖硬件的不同有所不同,因为在用户空间和内核空间传递参数的方式在不同的硬件上可能有差异。上述函数的任务主要是从处理器寄存器中提取由用户空间提供的信息,然后调用硬件无关的do_fork
函数。
以x86平台为例,它的sys_fork如下:
asmlinkage int sys_fork(struct pt_regs regs)
{
return do_fork(SIGCHLD, regs.esp, ®s, 0, NULL, NULL);
}
// 其中SIGCHLD标志意味着子进程终止后发送SIGCHLD信号
// 通知父进程。
可以看到在x86(32)下,不需要额外的操作,可以直接调用do_fork函数。
do_fork
do_fork
函数定义如下
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr){}
clone_flags
:标识集合,用来指定复制过程的一些属性stask_start
:用户状态下栈起始地址regs
:指向寄存器集合指针,其中以原始形式保存了调用参数,按照系统调用执行时寄存器在内核栈上的存储顺序,保存了所有寄存器,其中也包括了切换到内核态时由硬件自动保存的寄存器信息(x86平台)stack_size
:用户状态下栈大小,通常不使用,置0parent_tidptr & child_tidptr
:是指向用户空间中地址的两个指针,分别指向父子进程的PID
do_fork
实现非常复杂,涉及到内存、信号、文件等多个子系统,因此下面在介绍实现时,对于涉及到具体子系统的部分,只是会简单介绍功能而不会深入细节,具体细节会在介绍相应子系统时重新介绍
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
struct task_struct *p;
int trace = 0;
long nr;
// 如果当前进程(也就是父进程)的ptrace调试标志不为0
// 说明父进程正在被跟踪调试,那么检查子进程是否需要被跟踪
// 如果需要则置CLONE_PTRACE标志位
if (unlikely(current->ptrace)) {
trace = fork_traceflag (clone_flags);
if (trace)
clone_flags |= CLONE_PTRACE;
}
// 核心工作,后面单独介绍
p = copy_process(clone_flags, stack_start, regs, stack_size,
child_tidptr, NULL);
/*
* Do this prior waking up the new thread - the thread pointer
* might get invalid after that point, if the thread exits quickly.
*/
if (!IS_ERR(p)) {
struct completion vfork;
/*
* this is enough to call pid_nr_ns here, but this if
* improves optimisation of regular fork()
*/
// 获取新进程PID数值(根据是否生成了新的命名空间,获取方式有所不同)
nr = (clone_flags & CLONE_NEWPID) ?
task_pid_nr_ns(p, current->nsproxy->pid_ns) :
task_pid_vnr(p);
// 如果设置了CLONE_PARENT_SETTID,则把子进程PID放到父进程用户空间指定地址
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, parent_tidptr);
// 如果使用vfork,那么父进程需要阻塞直至子进程调用execve或exit
// 这里设置子进程的vfork_done并初始化,后面可以看到父进程会在
// vfork上睡眠,这样子进程就能在合适的时候唤醒父进程了。
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
}
// 如果处于调试状态或设置了CLONE_STOPPED,那么设置子进程SIGSTOP标志
// 并设置TIF_SIGPENDING标志,这样当子进程从内核态返回时就会处理该信号
if ((p->ptrace & PT_PTRACED) || (clone_flags & CLONE_STOPPED)) {
/*
* We'll start up with an immediate SIGSTOP.
*/
sigaddset(&p->pending.signal, SIGSTOP);
set_tsk_thread_flag(p, TIF_SIGPENDING);
}
// 如果没有设置STOP标志,那么调用wake_up_new_task唤醒新进程
// 该函数将新进程添加到调度器队列中,调度器可能会对新进程特殊处理
// 使其尽快执行。(对子进程调度器相关信息初始化在操作在copy_process中)
if (!(clone_flags & CLONE_STOPPED))
wake_up_new_task(p, clone_flags);
else
// 如果设置了STOP标志,则修改新进程状态
p->state = TASK_STOPPED;
// 跟踪调试相关
if (unlikely (trace)) {
current->ptrace_message = nr;
// 信号相关函数,使当前进程停止运行,并且向当前进程的父进程
// 也就是debugger进程发送SIGTRAP, 并且可以在ptrace_message中
// 找到子进程的pid
ptrace_notify ((trace << 8) | SIGTRAP);
}
// 如果使用了vfork,那么阻塞父进程
if (clone_flags & CLONE_VFORK) {
freezer_do_not_count();
// 可以看到,如果使用了vfork,父进程最终会在这里睡眠
// 等待子进程唤醒
wait_for_completion(&vfork);
freezer_count();
if (unlikely (current->ptrace & PT_TRACE_VFORK_DONE)) {
// 同样的向当前进程的debugger进程发送信号
current->ptrace_message = nr;
ptrace_notify ((PTRACE_EVENT_VFORK_DONE << 8) | SIGTRAP);
}
}
} else {
nr = PTR_ERR(p);
}
return nr;
}
可以看到do_fork
函数内部逻辑并不复杂,主要逻辑已经在代码中注释了,下面继续梳理一下
- 判断新进程是否需要被跟踪调试
- 调用核心逻辑
copy_process
函数 - 如果设置了调试标志或停止标志,那么设置子进程的SIGSTOP信号,并设置TIF_SIGPENDING,表明子进程有挂起信号待处理,SIGSTOP信号会使得子进程被停止
- 如果子进程没有被设置STOP标志,那么调用wake_up_new_task,把新进程插入到就绪队列中,否则设置子进程标识为STOP
- 如果设置了vfork标志,那么阻塞父进程,直到子进程唤醒后父进程才能继续执行。
主要的拷贝逻辑都在copy_process
中,让我们来看一下该函数:
static struct task_struct *copy_process(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid)
{
int retval;
struct task_struct *p;
int cgroup_callbacks_done = 0;
// 正确性校验,如果即要创建新的命名空间,又要共享文件系统信息,这是不合法的
if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))
return ERR_PTR(-EINVAL);
/*
* Thread groups must share signals as well, and detached threads
* can only be started up within the thread group.
*/
// 创建线程时必须要激活共享信号
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);
/*
* Shared signal handlers imply shared VM. By way of the above,
* thread groups also imply shared VM. Blocking this case allows
* for various simplifications in other code.
*/
// 只有父子进程共享了虚拟地址空间,才能够共享信号信号处理程序
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL);
// Linux 安全模块(LSM)钩子,可以注册,如果没有注册则空操作
retval = security_task_create(clone_flags);
if (retval)
goto fork_out;
retval = -ENOMEM;
// 拷贝父进程
p = dup_task_struct(current);
if (!p)
goto fork_out;
// 初始化锁
rt_mutex_init_task(p);
#ifdef CONFIG_TRACE_IRQFLAGS
DEBUG_LOCKS_WARN_ON(!p->hardirqs_enabled);
DEBUG_LOCKS_WARN_ON(!p->softirqs_enabled);
#endif
retval = -EAGAIN;
// 判断当前用户进程数是否超过最大限制
// 如果超出则判断当前用户是否有权限增大限制,如果没有则失败
if (atomic_read(&p->user->processes) >=
p->signal->rlim[RLIMIT_NPROC].rlim_cur) {
if (!capable(CAP_SYS_ADMIN) && !capable(CAP_SYS_RESOURCE) &&
p->user != current->nsproxy->user_ns->root_user)
goto bad_fork_free;
}
// 修改引用计数器和用户进程数
atomic_inc(&p->user->__count);
atomic_inc(&p->user->processes);
get_group_info(p->group_info);
/*
* If multiple threads are within copy_process(), then this check
* triggers too late. This doesn't hurt, the check is only there
* to stop root fork bombs.
*/
// nr_threads表示系统中当前进程数(不包括idle)
// max_threds表示系统最大进程数
// 如果超过则报错
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count;
// 模块相关,忽略
if (!try_module_get(task_thread_info(p)->exec_domain->module))
goto bad_fork_cleanup_count;
if (p->binfmt && !try_module_get(p->binfmt->module))
goto bad_fork_cleanup_put_domain;
// 进程创建完没有被执行过,置0
p->did_exec = 0;
delayacct_tsk_init(p); /* Must remain after dup_task_struct() */
copy_flags(clone_flags, p);
// 初始化字段
INIT_LIST_HEAD(&p->children);
INIT_LIST_HEAD(&p->sibling);
p->vfork_done = NULL;
spin_lock_init(&p->alloc_lock);
// 清除父进程拷贝过来的标志位
clear_tsk_thread_flag(p, TIF_SIGPENDING);
init_sigpending(&p->pending);
// 初始化字段
p->utime = cputime_zero;
p->stime = cputime_zero;
p->gtime = cputime_zero;
p->utimescaled = cputime_zero;
p->stimescaled = cputime_zero;
p->prev_utime = cputime_zero;
p->prev_stime = cputime_zero;
...
// 大内核锁
p->lock_depth = -1; /* -1 = no lock */
// 舒适化start_time和real_start_time
do_posix_clock_monotonic_gettime(&p->start_time);
p->real_start_time = p->start_time;
monotonic_to_bootbased(&p->real_start_time);
...
/* Perform scheduler related setup. Assign this task to a CPU. */
// 设置调度器相关信息,为进行重新设置调度器类,
// 初始化调度实体等..
sched_fork(p, clone_flags);
// LSM钩子
if ((retval = security_task_alloc(p)))
goto bad_fork_cleanup_policy;
if ((retval = audit_alloc(p)))
goto bad_fork_cleanup_security;
/* copy all the process information */
// 拷贝信号量
if ((retval = copy_semundo(clone_flags, p)))
goto bad_fork_cleanup_audit;
// 拷贝打开的文件
if ((retval = copy_files(clone_flags, p)))
goto bad_fork_cleanup_semundo;
// 拷贝文件系统
if ((retval = copy_fs(clone_flags, p)))
goto bad_fork_cleanup_files;
// 拷贝信号处理程序
if ((retval = copy_sighand(clone_flags, p)))
goto bad_fork_cleanup_fs;
// 拷贝信号
if ((retval = copy_signal(clone_flags, p)))
goto bad_fork_cleanup_sighand;
// 拷贝内存
if ((retval = copy_mm(clone_flags, p)))
goto bad_fork_cleanup_signal;
if ((retval = copy_keys(clone_flags, p)))
goto bad_fork_cleanup_mm;
// 拷贝命名空间
if ((retval = copy_namespaces(clone_flags, p)))
goto bad_fork_cleanup_keys;
// 拷贝thread_info信息
retval = copy_thread(0, clone_flags, stack_start, stack_size, p, regs);
if (retval)
goto bad_fork_cleanup_namespaces;
// 通过do_fork调用时pid ==NULL
if (pid != &init_struct_pid) {
retval = -ENOMEM;
// 申请新pid实例
pid = alloc_pid(task_active_pid_ns(p));
if (!pid)
goto bad_fork_cleanup_namespaces;
if (clone_flags & CLONE_NEWPID) {
retval = pid_ns_prepare_proc(task_active_pid_ns(p));
if (retval < 0)
goto bad_fork_free_pid;
}
}
// 获取pid实例局部pid值
p->pid = pid_nr(pid);
p->tgid = p->pid;
// 如果是线程则设置线程组id为父进程的线程组id
if (clone_flags & CLONE_THREAD)
p->tgid = current->tgid;
// 如果设置了CLONE_CHILD_SETTID和CLONE_CHILD_CLEARTID标志的额外设置
p->set_child_tid = (clone_flags & CLONE_CHILD_SETTID) ? child_tidptr : NULL;
/*
* Clear TID on mm_release()?
*/
p->clear_child_tid = (clone_flags & CLONE_CHILD_CLEARTID) ? child_tidptr: NULL;
...
/*
* sigaltstack should be cleared when sharing the same VM
*/
// 不理解
if ((clone_flags & (CLONE_VM|CLONE_VFORK)) == CLONE_VM)
p->sas_ss_sp = p->sas_ss_size = 0;
/*
* Syscall tracing should be turned off in the child regardless
* of CLONE_PTRACE.
*/
clear_tsk_thread_flag(p, TIF_SYSCALL_TRACE);
/* Our parent execution domain becomes current domain
These must match for thread signalling to apply */
p->parent_exec_id = p->self_exec_id;
/* ok, now we should be set up.. */
// 初始化字段
p->exit_signal = (clone_flags & CLONE_THREAD) ? -1 : (clone_flags & CSIGNAL);
p->pdeath_signal = 0;
p->exit_state = 0;
/*
* Ok, make it visible to the rest of the system.
* We dont wake it up yet.
*/
// 初始化字段
p->group_leader = p;
INIT_LIST_HEAD(&p->thread_group);
INIT_LIST_HEAD(&p->ptrace_children);
INIT_LIST_HEAD(&p->ptrace_list);
/* Now that the task is set up, run cgroup callbacks if
* necessary. We need to run them before the task is visible
* on the tasklist. */
cgroup_fork_callbacks(p);
cgroup_callbacks_done = 1;
/* Need tasklist lock for parent etc handling! */
write_lock_irq(&tasklist_lock);
/* for sys_ioprio_set(IOPRIO_WHO_PGRP) */
p->ioprio = current->ioprio;
/*
* The task hasn't been attached yet, so its cpus_allowed mask will
* not be changed, nor will its assigned CPU.
*
* The cpus_allowed mask of the parent may have changed after it was
* copied first time - so re-copy it here, then check the child's CPU
* to ensure it is on a valid CPU (and if not, just force it back to
* parent's CPU). This avoids alot of nasty races.
*/
p->cpus_allowed = current->cpus_allowed;
if (unlikely(!cpu_isset(task_cpu(p), p->cpus_allowed) ||
!cpu_online(task_cpu(p))))
set_task_cpu(p, smp_processor_id());
/* CLONE_PARENT re-uses the old parent */
// 如果设置了CLONE_PARENT或者CLONE_THREAD,则
// 新进程的real_parent不等于parent
if (clone_flags & (CLONE_PARENT|CLONE_THREAD))
p->real_parent = current->real_parent;
else
p->real_parent = current;
p->parent = p->real_parent;
spin_lock(¤t->sighand->siglock);
/*
* Process group and session signals need to be delivered to just the
* parent before the fork or both the parent and the child after the
* fork. Restart if a signal comes in before we add the new process to
* it's process group.
* A fatal signal pending means that current will exit, so the new
* thread can't slip out of an OOM kill (or normal SIGKILL).
*/
// 信号相关
recalc_sigpending();
if (signal_pending(current)) {
spin_unlock(¤t->sighand->siglock);
write_unlock_irq(&tasklist_lock);
retval = -ERESTARTNOINTR;
goto bad_fork_free_pid;
}
// 如果创建线程
if (clone_flags & CLONE_THREAD) {
p->group_leader = current->group_leader;
list_add_tail_rcu(&p->thread_group, &p->group_leader->thread_group);
if (!cputime_eq(current->signal->it_virt_expires,
cputime_zero) ||
!cputime_eq(current->signal->it_prof_expires,
cputime_zero) ||
current->signal->rlim[RLIMIT_CPU].rlim_cur != RLIM_INFINITY ||
!list_empty(¤t->signal->cpu_timers[0]) ||
!list_empty(¤t->signal->cpu_timers[1]) ||
!list_empty(¤t->signal->cpu_timers[2])) {
/*
* Have child wake up on its first tick to check
* for process CPU timers.
*/
p->it_prof_expires = jiffies_to_cputime(1);
}
}
// 处理PID相关工作
if (likely(p->pid)) {
// 将新进程添加到父进程的子进程链表中
add_parent(p);
// 如果新进程指定了PT_PTRACED标志
if (unlikely(p->ptrace & PT_PTRACED))
// 将新进程的parent指向调试进程,并且
// 将新进程从原来父进程链表中移除
// 添加到调试进程的链表中
__ptrace_link(p, current->parent);
if (thread_group_leader(p)) {
if (clone_flags & CLONE_NEWPID)
p->nsproxy->pid_ns->child_reaper = p;
p->signal->tty = current->signal->tty;
set_task_pgrp(p, task_pgrp_nr(current));
set_task_session(p, task_session_nr(current));
attach_pid(p, PIDTYPE_PGID, task_pgrp(current));
attach_pid(p, PIDTYPE_SID, task_session(current));
list_add_tail_rcu(&p->tasks, &init_task.tasks);
__get_cpu_var(process_counts)++;
}
// 将前面申请的pid关联到新进程
attach_pid(p, PIDTYPE_PID, pid);
// 增加全局进程数
nr_threads++;
}
total_forks++;
spin_unlock(¤t->sighand->siglock);
write_unlock_irq(&tasklist_lock);
proc_fork_connector(p);
cgroup_post_fork(p);
return p;
// 错误返回
bad_fork_free_pid:
if (pid != &init_struct_pid)
free_pid(pid);
bad_fork_cleanup_namespaces:
exit_task_namespaces(p);
bad_fork_cleanup_keys:
exit_keys(p);
bad_fork_cleanup_mm:
if (p->mm)
mmput(p->mm);
bad_fork_cleanup_signal:
cleanup_signal(p);
bad_fork_cleanup_sighand:
__cleanup_sighand(p->sighand);
bad_fork_cleanup_fs:
exit_fs(p); /* blocking */
bad_fork_cleanup_files:
exit_files(p); /* blocking */
bad_fork_cleanup_semundo:
exit_sem(p);
bad_fork_cleanup_audit:
audit_free(p);
bad_fork_cleanup_security:
security_task_free(p);
bad_fork_cleanup_policy:
#ifdef CONFIG_NUMA
mpol_free(p->mempolicy);
bad_fork_cleanup_cgroup:
#endif
cgroup_exit(p, cgroup_callbacks_done);
delayacct_tsk_free(p);
if (p->binfmt)
module_put(p->binfmt->module);
bad_fork_cleanup_put_domain:
module_put(task_thread_info(p)->exec_domain->module);
bad_fork_cleanup_count:
put_group_info(p->group_info);
atomic_dec(&p->user->processes);
free_uid(p->user);
bad_fork_free:
free_task(p);
fork_out:
return ERR_PTR(retval);
}
可以看到copy_process
主要做了一下几件事情
- 参数合法性校验
dup_task_struct
获取新进程的task_struct
以及thread_info
,并且利用父进程的信息为其初始化- 校验当前用户资源是否已经达到上限,并进一步校验系统资源是否已经达到上限,然后初始化
task_struct
一些字段 - 调用
sched_fork
设置新进程调度相关信息,如设置进程调度类,在此过程中需要确认没有将父进程提高的优先级泄漏到子进程。 - 拷贝父进程IPC、信号、信号处理程序、打开的文件、文件系统、内存、命名空间、thread_info等信息。
- 为新进程创建pid实例,将相关upid插入到pid_hash数组中,并且在后面把pid和新进程关联
- 继续初始化一些字段…
以上就是创建一个新进程的全部工作,诚然有些细节部分没有介绍到(没弄明白..),但是对于我们理解进程的创建来说,以上已经足够了。下面将继续深入copy_process
内部的函数进一步的介绍:
首先看一下第一个重要逻辑dup_task_struct
函数:
static struct task_struct *dup_task_struct(struct task_struct *orig)
{
struct task_struct *tsk;
struct thread_info *ti;
int err;
// 拷贝前的准备,主要是采用惰性fpu操作
// 保存父进程的fpu信息但是并不加载新进程的pfu信息
// 只有当新进程真正使用到fpu时,硬件会产生一个异常
// 此时内核才会真正的加载新进程的fpu
prepare_to_copy(orig);
// 从slab缓存中申请task_struct对象
tsk = alloc_task_struct();
if (!tsk)
return NULL;
// 为新的task_struct申请thread_info
// 同样从slab中申请
ti = alloc_thread_info(tsk);
if (!ti) {
free_task_struct(tsk);
return NULL;
}
// 拷贝父进程并将其中的thread_info修改为子进程的thread_info
*tsk = *orig;
tsk->stack = ti;
// 初始化一些信息
err = prop_local_init_single(&tsk->dirties);
if (err) {
free_thread_info(ti);
free_task_struct(tsk);
return NULL;
}
// 拷贝父进程的thread_info信息并将
// 子进程的thread_info指向子进程
setup_thread_stack(tsk, orig);
#ifdef CONFIG_CC_STACKPROTECTOR
tsk->stack_canary = get_random_int();
#endif
/* One for us, one for whoever does the "release_task()" (usually parent) */
// 设置新进程引用信息
atomic_set(&tsk->usage,2);
atomic_set(&tsk->fs_excl, 0);
#ifdef CONFIG_BLK_DEV_IO_TRACE
tsk->btrace_seq = 0;
#endif
tsk->splice_pipe = NULL;
return tsk;
}
可以看到dup_task_struct
函数其实主要做了一件事:初始化新进程的task_struct
和thread_info
,完全拷贝父进程对应信息。
接下来我们进一步查看调度相关初始化函数sched_fork
函数:
void sched_fork(struct task_struct *p, int clone_flags)
{
// 禁用内核抢占并且获取当前cpu,如果不禁用抢占的话
// 那么当前进程如果被抢占,那么下次执行的时候就不一定是在
// 当前cpu了,但是cpu变量的值还是之前的cpu,这是错误行为
int cpu = get_cpu();
// 主要是初始化task_struct调度实体各个字段,
// 并且把新进程状态设置为RUNNING
__sched_fork(p);
#ifdef CONFIG_SMP
// 多处理器下的负载均衡,找到一个合适的处理器
cpu = sched_balance_self(cpu, SD_BALANCE_FORK);
#endif
// 将新进程赋给该cpu,这里面做的事情比较多
// 简单的一点就是将子进程thread_info的cpu字段
// 设置为该cpu, 并且把当前进程调度尸体的cfs_rq
// 队列指向该处理器的队列
set_task_cpu(p, cpu);
/*
* Make sure we do not leak PI boosting priority to the child:
*/
// 根据父进程的普通优先级,决定新进程是由
// cfs调度器调度还是保持父进程一样,因为父进程此时可能
// 由于某种操作,暂时使用了实时调度器,但是本质上
// 父进程仍然是一个普通进程,因此为了防止子进程在这种情况下
// 变成实时进程,要通过父进程的普通优先级进行判断
p->prio = current->normal_prio;
if (!rt_prio(p->prio))
p->sched_class = &fair_sched_class;
#if defined(CONFIG_SCHEDSTATS) || defined(CONFIG_TASK_DELAY_ACCT)
if (likely(sched_info_on()))
memset(&p->sched_info, 0, sizeof(p->sched_info));
#endif
#if defined(CONFIG_SMP) && defined(__ARCH_WANT_UNLOCKED_CTXSW)
p->oncpu = 0;
#endif
#ifdef CONFIG_PREEMPT
/* Want to start with kernel preemption disabled. */
// 如果编译内核时禁止内核抢占,那么直接设置禁止抢占标志
// 这样该进程无论何时在内核态执行时都不能被其他进程抢占
task_thread_info(p)->preempt_count = 1;
#endif
put_cpu();
}
sched_fork
主要工作是初始化新进程调度相关信息,设置正确的调度类,为子进程选择合适的cpu,如果内核编译时禁止内核抢占,那么在初始化时会直接设置禁止抢占标志。对于cfs队列等信息在后面介绍调度器时再深入探讨,这里忽略即可。
下面就是一系列cpoy_xxx函数,我们一个一个来,首先是ipc拷贝函数copy_semundo
int copy_semundo(unsigned long clone_flags, struct task_struct *tsk)
{
struct sem_undo_list *undo_list;
int error;
// 如果拷贝时设置了共享ipc标志,那么子进程直接共享父进程的ipc
// 并且增加父进程ipc结构的引用计数
if (clone_flags & CLONE_SYSVSEM) {
error = get_undo_list(&undo_list);
if (error)
return error;
atomic_inc(&undo_list->refcnt);
tsk->sysvsem.undo_list = undo_list;
} else
// 否则直接将子进程的ipc队列置null
tsk->sysvsem.undo_list = NULL;
return 0;
}
可以看到该函数逻辑非常简单,至于undo_list结构的含义,等后面介绍ipc时再行介绍
紧接着是copy_files
函数,拷贝打开的文件
static int copy_files(unsigned long clone_flags, struct task_struct * tsk)
{
struct files_struct *oldf, *newf;
int error = 0;
/*
* A background process may not have any files ...
*/
// 如果父进程没有打开任何文件,那么子进程什么也不做
oldf = current->files;
if (!oldf)
goto out;
// 如果设置了共享文件标志,那么子进程直接引用父进程
// 文件结构,并增加引用计数
if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count);
goto out;
}
/*
* Note: we may be using current for both targets (See exec.c)
* This works because we cache current->files (old) as oldf. Don't
* break this.
*/
// 走到这里说明父子进程并不共享打开的文件
// 那么就需要拷贝父进程的文件结构并且复制给子进程
// 具体的拷贝逻辑涉及到文件系统,不是本文重点
// 可在文件系统相关知识介绍完后再回来查看
tsk->files = NULL;
newf = dup_fd(oldf, &error);
if (!newf)
goto out;
tsk->files = newf;
error = 0;
out:
return error;
}
然后是copy_fs
,拷贝父进程文件系统
static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
{
// 如果设置了共享文件系统,那么简单的增加父进程文件系统
// 结构的引用计数即可
if (clone_flags & CLONE_FS) {
atomic_inc(¤t->fs->count);
return 0;
}
// 否则拷贝父进程文件系统结构,拷贝的逻辑也比较简单
// 就是利用slab新申请一个fs结构,然后将父进程的
// 各项字段复制给新的
tsk->fs = __copy_fs_struct(current->fs);
if (!tsk->fs)
return -ENOMEM;
return 0;
}
随后调用copy_sighand
拷贝信号处理程序
static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
{
struct sighand_struct *sig;
// 如果可以共享,则增加父进程sighand引用计数即可
if (clone_flags & (CLONE_SIGHAND | CLONE_THREAD)) {
atomic_inc(¤t->sighand->count);
return 0;
}
// 从slab申请一个sighand实例
sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
// rcu机制(内核同步手段,不是本节重点)
// 将新的sighand实例赋值给新进程
rcu_assign_pointer(tsk->sighand, sig);
if (!sig)
return -ENOMEM;
// 设置引用计数,然后暴力拷贝即可
atomic_set(&sig->count, 1);
memcpy(sig->action, current->sighand->action, sizeof(sig->action));
return 0;
}
信号处理程序之后紧接着拷贝信号,该工作由copy_signal
完成
static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
{
struct signal_struct *sig;
int ret;
// 如果可以共享,增加相应引用计数即可
if (clone_flags & CLONE_THREAD) {
atomic_inc(¤t->signal->count);
atomic_inc(¤t->signal->live);
return 0;
}
// slab申请新实例并赋值给新进程
sig = kmem_cache_alloc(signal_cachep, GFP_KERNEL);
tsk->signal = sig;
if (!sig)
return -ENOMEM;
// 如果编译内核时设置了CONFIG_KEYS,那么拷贝同一个线程组的密钥环(自行搜索)
// 否则这一步其实什么也没做
ret = copy_thread_group_keys(tsk);
if (ret < 0) {
kmem_cache_free(signal_cachep, sig);
return ret;
}
// 初始化signal结构一系列字段,具体是什么含义可以忽略
// 涉及到信号相关知识,后面介绍完信号再回来看就能理解了
atomic_set(&sig->count, 1);
atomic_set(&sig->live, 1);
init_waitqueue_head(&sig->wait_chldexit);
sig->flags = 0;
sig->group_exit_code = 0;
sig->group_exit_task = NULL;
sig->group_stop_count = 0;
sig->curr_target = NULL;
init_sigpending(&sig->shared_pending);
INIT_LIST_HEAD(&sig->posix_timers);
hrtimer_init(&sig->real_timer, CLOCK_MONOTONIC, HRTIMER_MODE_REL);
sig->it_real_incr.tv64 = 0;
sig->real_timer.function = it_real_fn;
sig->tsk = tsk;
sig->it_virt_expires = cputime_zero;
sig->it_virt_incr = cputime_zero;
sig->it_prof_expires = cputime_zero;
sig->it_prof_incr = cputime_zero;
sig->leader = 0; /* session leadership doesn't inherit */
sig->tty_old_pgrp = NULL;
sig->utime = sig->stime = sig->cutime = sig->cstime = cputime_zero;
sig->gtime = cputime_zero;
sig->cgtime = cputime_zero;
sig->nvcsw = sig->nivcsw = sig->cnvcsw = sig->cnivcsw = 0;
sig->min_flt = sig->maj_flt = sig->cmin_flt = sig->cmaj_flt = 0;
sig->inblock = sig->oublock = sig->cinblock = sig->coublock = 0;
sig->sum_sched_runtime = 0;
INIT_LIST_HEAD(&sig->cpu_timers[0]);
INIT_LIST_HEAD(&sig->cpu_timers[1]);
INIT_LIST_HEAD(&sig->cpu_timers[2]);
taskstats_tgid_init(sig);
task_lock(current->group_leader);
memcpy(sig->rlim, current->signal->rlim, sizeof sig->rlim);
task_unlock(current->group_leader);
if (sig->rlim[RLIMIT_CPU].rlim_cur != RLIM_INFINITY) {
/*
* New sole thread in the process gets an expiry time
* of the whole CPU time limit.
*/
tsk->it_prof_expires =
secs_to_cputime(sig->rlim[RLIMIT_CPU].rlim_cur);
}
acct_init_pacct(&sig->pacct);
tty_audit_fork(sig);
return 0;
}
接着是拷贝mm,写时复制的秘密就在这里,调用copy_mm
函数
static int copy_mm(unsigned long clone_flags, struct task_struct * tsk)
{
struct mm_struct * mm, *oldmm;
int retval;
// 初始化缺页统计信息
// min_flt表示页在内存中产生的缺页,但是由于还没有和
// 进程地址建立映射关系
// maj_flt表示由于页不在内存中产生的缺页
tsk->min_flt = tsk->maj_flt = 0;
// 初始化资源/非资源进程上下文切换计数
tsk->nvcsw = tsk->nivcsw = 0;
// 置空mm和active_mm,active_mm主要是给内核
// 线程使用的,因为内核线程没有mm结构体
// 因此只能使用当前进程的mm
tsk->mm = NULL;
tsk->active_mm = NULL;
/*
* Are we cloning a kernel thread?
*
* We need to steal a active VM for that..
*/
oldmm = current->mm;
// 第一个内核线程是由idle创建的,并且通过查看
// idle进程的task_struct(include/linux/init_task.h)发现
// idle进程并没有mm结构体,因此内核线程也就没有mm结构体
if (!oldmm)
return 0;
// 如果共享mm,那么增加引用计数即可
if (clone_flags & CLONE_VM) {
atomic_inc(&oldmm->mm_users);
mm = oldmm;
goto good_mm;
}
retval = -ENOMEM;
// 否则拷贝mm结构体
mm = dup_mm(tsk);
if (!mm)
goto fail_nomem;
good_mm:
/* Initializing for Swap token stuff */
mm->token_priority = 0;
mm->last_interval = 0;
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
fail_nomem:
return retval;
}
可以看到copy_mm
主要是初始化一些统计信息,并且根据是否需要共享mm来决定是共享父进程的mm还是拷贝父进程的mm,如果父子进程共享mm,那么就不存在写时复制这一说了,我们主要分析不共享的情况,也就是dup_mm
函数
static struct mm_struct *dup_mm(struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm = current->mm;
int err;
if (!oldmm)
return NULL;
// 千篇一律,使用slab获取mm对象
mm = allocate_mm();
if (!mm)
goto fail_nomem;
// 暴力拷贝父进程的mm
memcpy(mm, oldmm, sizeof(*mm));
/* Initializing for Swap token stuff */
// 页面交换相关
mm->token_priority = 0;
mm->last_interval = 0;
// 初始化新申请的mm
if (!mm_init(mm))
goto fail_nomem;
// 初始化上下文
if (init_new_context(tsk, mm))
goto fail_nocontext;
// 开始拷贝
err = dup_mmap(mm, oldmm);
if (err)
goto free_pt;
// 初始化进程拥有的最大页框数和进程线性区中最大页数
// 页框指的是物理页,页指的是虚拟地址
mm->hiwater_rss = get_mm_rss(mm);
mm->hiwater_vm = mm->total_vm;
return mm;
free_pt:
mmput(mm);
fail_nomem:
return NULL;
fail_nocontext:
/*
* If init_new_context() failed, we cannot use mmput() to free the mm
* because it calls destroy_context()
*/
mm_free_pgd(mm);
free_mm(mm);
return NULL;
}
可以看到dup_mm
函数的主要逻辑也不难,主要是从slab申请新的mm_struct
结构,然后暴力拷贝父进程的mm,不过对其中一些统计信息进行初始化,然后依次调用三个函数,分别是mm_init
、init_new_context
以及dup_mmap
,我们一个一个说
首先是mm_init
函数:
static struct mm_struct * mm_init(struct mm_struct * mm)
{
// 可以看到只是简单的初始化mm_struct各项字段罢了
// 具体字段的含义涉及到内存管理,这里不展开
// 在内存相关文章中会详细介绍
atomic_set(&mm->mm_users, 1);
atomic_set(&mm->mm_count, 1);
init_rwsem(&mm->mmap_sem);
INIT_LIST_HEAD(&mm->mmlist);
mm->flags = (current->mm) ? current->mm->flags
: MMF_DUMP_FILTER_DEFAULT;
mm->core_waiters = 0;
mm->nr_ptes = 0;
set_mm_counter(mm, file_rss, 0);
set_mm_counter(mm, anon_rss, 0);
spin_lock_init(&mm->page_table_lock);
rwlock_init(&mm->ioctx_list_lock);
mm->ioctx_list = NULL;
mm->free_area_cache = TASK_UNMAPPED_BASE;
mm->cached_hole_size = ~0UL;
// 为新的mm申请pgd并且进行进一步处理
if (likely(!mm_alloc_pgd(mm))) {
mm->def_flags = 0;
return mm;
}
free_mm(mm);
return NULL;
}
static inline int mm_alloc_pgd(struct mm_struct * mm)
{
// 调用pgd_alloc函数
mm->pgd = pgd_alloc(mm);
if (unlikely(!mm->pgd))
return -ENOMEM;
return 0;
}
pgd_t *pgd_alloc(struct mm_struct *mm)
{
int i;
// quicklist_alloc该函数主要从每cpu缓存中申请一个pgd页
// 如果申请失败则从伙伴系统申请,并且调用pgd_ctor初始化
// 具体就不深入研究了,涉及到内存管理,比较复杂
// 会在内存管理文章中详细介绍申请过程,不过可以看一下
// pgd_ctor函数,该函数初始化新进程的pgd时,
// 会将swagger_pg_dir(也就是idle进程的页表)的内核页部分(最高1GB)
// 拷贝给新进程的内核页部分,这样一来,所有新进程的内核页表部分
// 都相同..因为它们实际上都是以swagger_pg_dir进程的模版拷贝的
pgd_t *pgd = quicklist_alloc(0, GFP_KERNEL, pgd_ctor);
if (PTRS_PER_PMD == 1 || !pgd)
return pgd;
// 为每个pgd项申请pmd页,并设置对应的pgd项
for (i = 0; i < UNSHARED_PTRS_PER_PGD; ++i) {
// 如果是内核部分的pmd,那么对于申请到的pmd页
// 同样适用swigger_pg_dir进行拷贝
// 如果是用户部分的页,那么从slab缓存获取一个
// pmd对象,该对象从pmd_cache获取,每个pmd都
// 被初始化过(对于pmd初始化实际上就是全部置0)
pmd_t *pmd = pmd_cache_alloc(i);
if (!pmd)
goto out_oom;
// 忽略
paravirt_alloc_pd(__pa(pmd) >> PAGE_SHIFT);
// 设置pgd项,至于为什么是1 + __pa(pmd)
// 可以看一下x86的硬件分页规则,Linux将页的
// R/W,U/S,PWT,PCD以及A标志都置0,表明该pmd页
// 是不可写、系统页、启用高速缓存并且对于写
// 操作总是回写(这和x86的硬件缓存有关,实际上Linux对于所有页
// 都是启动缓存并且回写)
set_pgd(&pgd[i], __pgd(1 + __pa(pmd)));
}
return pgd;
out_oom:
for (i--; i >= 0; i--) {
pgd_t pgdent = pgd[i];
void* pmd = (void *)__va(pgd_val(pgdent)-1);
paravirt_release_pd(__pa(pmd) >> PAGE_SHIFT);
pmd_cache_free(pmd, i);
}
quicklist_free(0, pgd_dtor, pgd);
return NULL;
}
可以看到该函数主要是初始化mm结构的各个字段,其中非常值得关注的一点是对于pgd
的初始化:每个子进程都会拷贝swagger_pg_dir的内核态pgd项和pmd项,因此这也就解释了为什么所有进程的内核页表都相同。初始化pgd
的做法就是为每一个pgd项都申请一个pmd页,然后利用pmd页的物理地址设置pgd表项,可以看到pgd表项被设置为不可读、系统页并且启用硬件高速缓存以及开启回写
的策略。
然后就是init_new_context
函数:
int init_new_context(struct task_struct *tsk, struct mm_struct *mm)
{
struct mm_struct * old_mm;
int retval = 0;
// 初始化锁
mutex_init(&mm->context.lock);
mm->context.size = 0;
old_mm = current->mm;
// 如果父进程的mm使用了ldt,那么拷贝给子进程
if (old_mm && old_mm->context.size > 0) {
mutex_lock(&old_mm->context.lock);
retval = copy_ldt(&mm->context, &old_mm->context);
mutex_unlock(&old_mm->context.lock);
}
return retval;
}
最后也是最重要的dup_mmap
函数
static int dup_mmap(struct mm_struct *mm, struct mm_struct *oldmm)
{
struct vm_area_struct *mpnt, *tmp, **pprev;
struct rb_node **rb_link, *rb_parent;
int retval;
unsigned long charge;
struct mempolicy *pol;
// 进入临界区
down_write(&oldmm->mmap_sem);
// x86平台上什么也不做
flush_cache_dup_mm(oldmm);
/*
* Not linked in yet - no deadlock potential:
*/
down_write_nested(&mm->mmap_sem, SINGLE_DEPTH_NESTING);
// 初始化子进程mm的若干字段
mm->locked_vm = 0;
mm->mmap = NULL;
mm->mmap_cache = NULL;
mm->free_area_cache = oldmm->mmap_base;
mm->cached_hole_size = ~0UL;
mm->map_count = 0;
cpus_clear(mm->cpu_vm_mask);
mm->mm_rb = RB_ROOT;
rb_link = &mm->mm_rb.rb_node;
rb_parent = NULL;
pprev = &mm->mmap;
// 遍历所有的线性区然后拷贝
for (mpnt = oldmm->mmap; mpnt; mpnt = mpnt->vm_next) {
struct file *file;
if (mpnt->vm_flags & VM_DONTCOPY) {
long pages = vma_pages(mpnt);
mm->total_vm -= pages;
vm_stat_account(mm, mpnt->vm_flags, mpnt->vm_file,
-pages);
continue;
}
charge = 0;
if (mpnt->vm_flags & VM_ACCOUNT) {
unsigned int len = (mpnt->vm_end - mpnt->vm_start) >> PAGE_SHIFT;
if (security_vm_enough_memory(len))
goto fail_nomem;
charge = len;
}
tmp = kmem_cache_alloc(vm_area_cachep, GFP_KERNEL);
if (!tmp)
goto fail_nomem;
*tmp = *mpnt;
pol = mpol_copy(vma_policy(mpnt));
retval = PTR_ERR(pol);
if (IS_ERR(pol))
goto fail_nomem_policy;
vma_set_policy(tmp, pol);
tmp->vm_flags &= ~VM_LOCKED;
tmp->vm_mm = mm;
tmp->vm_next = NULL;
anon_vma_link(tmp);
file = tmp->vm_file;
if (file) {
struct inode *inode = file->f_path.dentry->d_inode;
get_file(file);
if (tmp->vm_flags & VM_DENYWRITE)
atomic_dec(&inode->i_writecount);
/* insert tmp into the share list, just after mpnt */
spin_lock(&file->f_mapping->i_mmap_lock);
tmp->vm_truncate_count = mpnt->vm_truncate_count;
flush_dcache_mmap_lock(file->f_mapping);
vma_prio_tree_add(tmp, mpnt);
flush_dcache_mmap_unlock(file->f_mapping);
spin_unlock(&file->f_mapping->i_mmap_lock);
}
/*
* Link in the new vma and copy the page table entries.
*/
*pprev = tmp;
pprev = &tmp->vm_next;
__vma_link_rb(mm, tmp, rb_link, rb_parent);
rb_link = &tmp->vm_rb.rb_right;
rb_parent = &tmp->vm_rb;
mm->map_count++;
// 这就是核心函数
retval = copy_page_range(mm, oldmm, mpnt);
if (tmp->vm_ops && tmp->vm_ops->open)
tmp->vm_ops->open(tmp);
if (retval)
goto out;
}
/* a new mm has just been created */
arch_dup_mmap(oldmm, mm);
retval = 0;
out:
up_write(&mm->mmap_sem);
flush_tlb_mm(oldmm);
up_write(&oldmm->mmap_sem);
return retval;
fail_nomem_policy:
kmem_cache_free(vm_area_cachep, tmp);
fail_nomem:
retval = -ENOMEM;
vm_unacct_memory(charge);
goto out;
}
这个函数很长,其他的暂时可以忽略,因为涉及到内存管理比较复杂,可以关注以下copy_page_range函数,该函数简单的说就是遍历pgd-pud-pmd-pte,然后循环拷贝父进程的项,并且在拷贝pte项时,清空父/子进程项的R/W标志,这样当父子进程试图写页面时,硬件就会产生一个页错误异常,内核捕捉到这个异常后就会进行写时复制。
对于把copy_mm
的介绍到这里就告一段落,对于拷贝密钥环这里就不做介绍了,一是我对这个不熟悉,二是这个和本节的内容也不想管,感兴趣的话可以研究一下,而copy_namespace
整体逻辑也不复杂,如果父子进程不共享命名空间,那么则拷贝一份父进程的副本给子进程,注意这里的命名空间包含了2.6支持的所有命名空间(nsproxy结构)
最后需要介绍的就是copy_thread
函数,该函数也是fork过程中十分重要的函数:
int copy_thread(int nr, unsigned long clone_flags, unsigned long esp,
unsigned long unused,
struct task_struct * p, struct pt_regs * regs)
{
struct pt_regs * childregs;
struct task_struct *tsk;
int err;
// 获取子进程内核态栈中的各个寄存器值
// 这些值都是前面函数中从父进程拷贝过来的,目前和父进程
// 一摸一样,因此这里可能需要进行修改, childregs位于
// 内核栈栈底向下8个字节
childregs = task_pt_regs(p);
*childregs = *regs;
// 修改fork在子进程中的返回值,我们知道fork最终返回pid
// 并且通过eax传递
// 父进程中的eax就是子进程的pid,而子进程的eax由于这里的
// 操作会返回0,这也就是为什么子进程返回0的原因
childregs->eax = 0;
// childregs->esp中现在存储的是父进程用户态基地址
// 如果在fork时制定了新的用户栈地址,那么修改子进程的
// 用户栈地址,这样子进程返回用户态时就和父进程使用
// 不同的栈了,一般在线程创建时会使用
childregs->esp = esp;
// 这两个字段主要是设置内核栈基地址 - 9 和内核栈基地址 - 8
// 具体看https://www.cnblogs.com/chaozhu/p/6283495.html
// https://www.coolcou.com/linux-kernel/linux-kernel-references/linux-kernel-stack.html
p->thread.esp = (unsigned long) childregs;
p->thread.esp0 = (unsigned long) (childregs+1);
// 这里修改了do_fork函数返回地址,设置为ret_from_fork
// 函数的地址,因此子进程返回时会执行ret_from_fork函数
p->thread.eip = (unsigned long) ret_from_fork;
// 把gs寄存器中的值保存到新进程的=threadinfo的指定位置
savesegment(gs,p->thread.gs);
// 判断父进程使用了IO权限位图,那么子进程拷贝一份
// 并且同样设置TIF_IO_BITMAP位
tsk = current;
if (unlikely(test_tsk_thread_flag(tsk, TIF_IO_BITMAP))) {
p->thread.io_bitmap_ptr = kmemdup(tsk->thread.io_bitmap_ptr,
IO_BITMAP_BYTES, GFP_KERNEL);
if (!p->thread.io_bitmap_ptr) {
p->thread.io_bitmap_max = 0;
return -ENOMEM;
}
set_tsk_thread_flag(p, TIF_IO_BITMAP);
}
/*
* Set a new TLS for the child thread?
*/
// 如果fork标志中指定了CLONE_SETTLS
// 那么需要为子进程创建线程本地存储
if (clone_flags & CLONE_SETTLS) {
struct desc_struct *desc;
struct user_desc info;
int idx;
err = -EFAULT;
// 用户空间通过esi指定了子进程ldt段描述符
if (copy_from_user(&info, (void __user *)childregs->esi, sizeof(info)))
goto out;
err = -EINVAL;
if (LDT_empty(&info))
goto out;
// 参数校验
idx = info.entry_number;
if (idx < GDT_ENTRY_TLS_MIN || idx > GDT_ENTRY_TLS_MAX)
goto out;
// 读取新进程tls_array的指定项,并且根据
// 用户指定的描述符内容进行填充
// https://www.cnblogs.com/long123king/p/3501936.html
// 具体可以参考这篇文章,Linux每个cpu一个gdt,并且
// 每个gdt预留了(789)三个项给tls使用
desc = p->thread.tls_array + idx - GDT_ENTRY_TLS_MIN;
desc->a = LDT_entry_a(&info);
desc->b = LDT_entry_b(&info);
}
err = 0;
out:
if (err && p->thread.io_bitmap_ptr) {
kfree(p->thread.io_bitmap_ptr);
p->thread.io_bitmap_max = 0;
}
return err;
}
对于do_fork
的介绍到这里就告一段落了,由于Linux本身的复杂性,新创建一个进程需要做的任务也是非常的庞大。需要特别说明的是,上面的介绍都是基于Linux2.6.24的x86-32位处理器的代码,并且其中涉及到文件系统、信号、ipc、调度以及内存管理的部分并没有深入介绍,不过这并不影响我们理解一个进程被创建出来的过程。
内核线程
上面一节介绍了一个用户进程是如何被创建出来的,这一节让我们把目光聚集在内核线程上(需要再次申明的是,Linux内部无论是线程还是进程,都使用task_struct表示,实际上Linux内部并不区分进程线程,通过上一届的进程复制过程可以发现,线程共享父进程的信号、ipc、内存、文件等信息,我们把所有共享同一组这些信息的线程统称为线程组,它们共享这些信息,因此在线程切换时不需要切换页表,刚创建时也不需要写时复制,不需要进行各种拷贝等..因此无论是创建还是切换的开销都要小于进程。回到前面来,由于内核实际上不区分进程还是线程,因此下面再介绍时可能会混用这两个概念)。内核线程是直接由内核本身启动的进程,内核线程实际上是将内核函数委托给独立的进程。内核线程也被称为守护进程,它们用于执行下列任务
- 周期性将修改的内存页与块设备(通常是磁盘)进行同步
- 如果内存页很少使用,则写入交换区
- 管理延时动作(比如软中断)
- 实现文件系统的事务日志
Linux有两种类型的内核线程
- 线程启动后一直等待,直到内核请求线程执行某一特定操作
- 线程启动后按周期性间隔执行,检测特定资源的使用,在使用量超出或低于预置的限制时采取行动,内核使用这类线程用于连续执行检测任务。
使用kernel_thread
可启动一个内核线程。
int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
{
struct pt_regs regs;
memset(®s, 0, sizeof(regs));
// ebx指向内核线程将要执行的函数,edx指向函数需要的参数
regs.ebx = (unsigned long) fn;
regs.edx = (unsigned long) arg;
// 设置内核线程内核堆栈内容
regs.xds = __USER_DS;
regs.xes = __USER_DS;
// linux在用户态使用fs引用tls,不使用gs
// 在内核态使用gs引用没cpu变量,不使用fs
// 这里的fs在内核线程返回用户态时会被加载到用户态fs
// 寄存器中,作为内核线程的tls?
// 2.6.24中使用每个cpu的gdt的28项(包括第0项)作为
// per_cpu变量的段描述符,实际上就是指向per_cpu变量
// 也就是说,内核线程的tls指向了per_cpu变量
regs.xfs = __KERNEL_PERCPU;
regs.orig_eax = -1;
// 设置内核进程的cs/ip,这样内核线程创建完成被调度执行时
// 就会从kernel_thread_helper开始执行,这里设置内核线程的
// CS为内核代码段,因为内核线程实际上执行的是内核函数
regs.eip = (unsigned long) kernel_thread_helper;
regs.xcs = __KERNEL_CS | get_kernel_rpl();
regs.eflags = X86_EFLAGS_IF | X86_EFLAGS_SF | X86_EFLAGS_PF | 0x2;
// 通过do_fork可以发现新创建的内核线程最终回执行ret_from_fork函数,该函数
// 返回到用户栈时会执行kernel_thread_helper函数
return do_fork(flags | CLONE_VM | CLONE_UNTRACED, 0, ®s, 0, NULL, NULL);
}
可以发现内核线程奇怪的一点是,它的代码段是内核代码段(cpl=0),但是数据段确实用户数据段(cpl=3),这是因为内核线程实际上没有自己的mm,但是由需要通过mm访问内核部分内存,这怎么办呢?原来,内核线程在被调度器调度执行时,会短暂使用前一个用户进程的mm结构体,这也就是task_struct为什么有两个mm相关字段,一个是mm,一个是active_mm。对于普通进程来说,mm和active_mm相同,而对于内核线程来说,它的mm为空,active_mm为前一个进程的mm结构体。
并且通过代码可以发现,内核线程永远只在内核态执行(因为cs的cpl = 0),另外内核线程只访问虚拟地址空间的内核部分,而不访问用户部分。
懒惰tlb模式
我们知道Linux上的全部虚拟地址空间分为两部分,底部的虚拟空间由用户程序访问,不同的用户程序不一祥,上层的空间供内核使用。每当进程切换时,虚拟地址空间的用户层部分就会被切换,切换成新进程的用户空间部分。这是懒惰tlb的基础,由于内核线程不用访问用户部分的虚拟空间,因此内核从普通进程切换到内核线程时,并不会立刻刷新当前进程的tlb表项,而是往后推迟。
为什么这样做呢?我们考虑这样一种情况,当前进程A切换到内核线程执行,内核线程执行完毕后,再次切换到了进程A,那么我们根本就不需要刷新tlb然后重新加载。但是如果内核线程下一个执行的进程和前面的进程不同,此时就需要执行tlb刷新,将之前进程的tlb内容清空。(多说一句,使用了类似x86平台下的pcid模式,那么不同进程的tlb表项可以共存在一个tlb上,这是因为不同进程有一个专属的picd,通过pcid可以区分不同进程的tlb表项,但是这样其实也存在一个问题,在多处理器下进程可能在多个处理器上运行过,这样一来多个处理器上都有某个进程的tlb项,万一需要清除该进程在tlb中的某个项或者某几个项,还需要借助处理器间中断来实现,还是比较麻烦的..目前版本的linux好像还不支持这一特性。)
创建内核线程方式
内核线程有两种方式实现:
-
第一种就是前面说的,调用
kernel_thread
,这种方式比较古老,并且不够优雅,为什么这么说呢?不知道你有没有发现,当非内核线程(也就是说该进程有mm结构)想要创建一个内核线程时,内核线程会继承父进程的资源,因此还需要在内核线程中调用daemonize
函数将新创建的内核线程转换为守护进程,该函数主要干了这几件事- 释放心创建内核线程的父进程资源(例如内存上下文、文件描述符等),不然内核线程会一直占用这些资源直到结束,这是不可取的,因为内核线程通常会运行到系统关机...并且内核线程不访问用户空间,所以它甚至不使用这些资源,但是却占用了资源
- 阻塞信号的接收
- 将2号进程作为内核线程的父进程(实际上所有内核线程的父进程都是2号线程)
-
第二种方式则更加现代,调用
kthread_create
或kthread_run
函数,这两个函数唯一的区别在于第二个创建后悔唤醒新创建的内核线程,而第一个不会主动唤醒。
对于内核线程的创建过程这里简单说一下:在start_kernel
的最后会执行rest_init
函数,该函数会创建1号init进程和2号kthread_add内核线程,其中1号进程最终会演变为用户进程,并且系统中所有的孤儿进程最终都会归init管理。而2号进程则会成为所有内核线程的祖先,所有的内核线程最终都通过2号内核祖先线程去创建:大概流程就是,现将要创建的内核线程包装一下放到一个队列中,然后唤醒kthread_add线程,该线程在一个死循环中执行,唤醒后查看队列中有没有待创建的内核线程,若有则创建之,若没有则修改状态为中断睡眠状态并调用schedule()函数。
进程执行
当一个新进程创建完成后并且被调度器选中执行时,它会紧接着执行ret_from_fork
函数(如果不理解可以看一下前面的copy_thread函数),ret_from_fork
函数主要是调用schedule_tail函数进行schedule()的收尾工作,包括允许强占、开中断、释放当前cpu就绪队列的自旋锁,并且如果判断前一个任务处于DEAD状态,说明父进程已经调用了wait4系统调用,那么回收前一个进程的剩余资源。
你可能有疑问,问什么一个新创建的进程要调用schedule_tail函数呢?其实可以这么理解,一个进程正常切换的流程大概是这样的:schedule() → context_switch() → switch_to() ----等待调度器分配cpu-----schedule_tail()恢复执行。那么对于一个新创建的进程,他在执行完前面的步骤时,是没有最后一步schedule_tail()的,因为它并不是之前被调度器停止执行的进程(换句话说它从来没有获得过cpu),所以这样就存在问题:schedule前半部执行了禁止抢占、禁止中断、cpu就绪队列自旋锁,就没有办法释放。因此我们需要人为的给新进程添加上schedule_tail(),让它第一次执行时先释放这些资源,然后再回复到用户态。
ENTRY(ret_from_fork)
CFI_STARTPROC
pushl %eax
CFI_ADJUST_CFA_OFFSET 4
call schedule_tail // 执行schedule()_tail收尾
GET_THREAD_INFO(%ebp)
popl %eax
CFI_ADJUST_CFA_OFFSET -4
pushl $0x0202 # Reset kernel eflags
CFI_ADJUST_CFA_OFFSET 4
popfl
CFI_ADJUST_CFA_OFFSET -4
jmp syscall_exit // 因为是fork系统调用,所以从系统调用中返回
CFI_ENDPROC
END(ret_from_fork)
可能上述的部分有些抽象,我们通过一个图来把它形象化:
如图所示,除了新创建的进程,Linux中所有进程在切换的时,被切换出去的进程(如图中的进程A),都会在switch_to函数中停止(可以简单理解为阻塞),然后获得CPU的进程(如图中的进程B)从switch_to中恢复执行(因为B上次被切换出去的时候也是阻塞在swtch_to中),通过图中我们还可以发现,此时系统是禁止本地中断(根据内核配置不同,可能会有区别,不过整体上逻辑一样)并且禁止抢占的,然而在B恢复执行后,会先执行finish_task_switch,该函数会主要两件事:如果前面没有开中断,那么这里开中断,如果前一个进程已经DEAD状态,那么释放task_struct和mm(其实还会把前一个进程的on_cpu置0,表示不持有cpu,不过和我们讨论的主题无关,忽略即可);然后进程B回到schedule()函数中继续执行,schedule()最后会允许抢占,最后进程B从schedule()退出,去执行进程B本来的逻辑。不的不感叹,schedule()简洁优美但是功能如此强大!!!
让我们回到主题上来,对于一些新创建的进程,它被schedule()调度后,将会恢复执行。由于新进程从来没有执行过,它该从哪里开始执行呢?根据前面的分析我们知道,此时本地cpu处于禁止抢占、关中断(可能)的,如果获取cpu的是一个老进程,那么这都不是问题,老进程会根据上面的步骤,开中断并允许抢占。但是对于新进程就不行了,因此我们很容易想到,我们需要给新进程模拟后面的步骤,让它像老进程一样,执行和老进程类似的动作就好了。
分析到这里,你应该知道为什么要设置新进程的ip为ret_from_fork
了,在该函数中会调用schedule_tail函数,而schedule_tail函数做的工作正是我们上面说的:
asmlinkage void schedule_tail(struct task_struct *prev)
__releases(rq->lock)
{
struct rq *rq = this_rq();
// 调用finish_task_switch,如果cpu仍然处于关中断状态
// 则开中断
finish_task_switch(rq, prev);
#ifdef __ARCH_WANT_UNLOCKED_CTXSW
/* In this case, finish_task_switch does not reenable preemption */
// 允许内核抢占
preempt_enable();
#endif
if (current->set_child_tid)
put_user(task_pid_vnr(current), current->set_child_tid);
}
说到这里,你可能对进程切换的过程更加好奇了,进程切换是Linux非常有意思的部分,使用非常短小的一段汇编就实现了切换,很值得我们细细研究。不过本节的主题是进程执行..好像有点偏题了,让我们回到正题,把进程执行介绍完毕。我将在本文的后面详细的介绍进程切换的细节。
当新进程经过上面的步骤返回到用户态执行时,由于子进程的用户态ip和父进程相同,因此子进程会执行和父进程一摸一样的代码,这是没有意义的。如果想要让子进程执行不同的逻辑,我们需要借助另外一个系统调用: execve
。该系统调用加载指定可执行文件并且执行,这样一来父子进程就完全不一样了。
和传统的系统调用一样,execve的系统调用入口是体系结构相关的sys_execve
函数,紧接着sys_execve
函数会调用体系结构无关的do_execve
函数,因此我们下面主要研究的就是do_execve
函数,在介绍该函数之前,需要先简单介绍一点前置知识
linux_binprm
该结构在execve函数中比较重要,它包含了进程执行新文件的所有信息,进程地址空间、可执行文件名、可执行文件、环境变量、程序参数等
struct linux_binprm{
char buf[BINPRM_BUF_SIZE]; // 用于存储可执行文件头部128字节
#ifdef CONFIG_MMU
struct vm_area_struct *vma; // 进程新的地址空间
#else
# define MAX_ARG_PAGES 32
struct page *page[MAX_ARG_PAGES];
#endif
struct mm_struct *mm; // 进程新的mm结构
unsigned long p; /* current top of mem */ // 进程当前内存的最大地址
int sh_bang; // 和执行sbin有关
struct file * file; // 可执行文件
int e_uid, e_gid; // 权限相关
kernel_cap_t cap_inheritable, cap_permitted;
bool cap_effective;
void *security;
int argc, envc; // 程序参数和环境变量个数
char * filename; /* Name of binary as seen by procps */
char * interp; /* Name of the binary really executed. Most
of the time same as filename, but could be
different for binfmt_{misc,script} */
unsigned interp_flags;
unsigned interp_data;
unsigned long loader, exec; // 分别指向loader开始的地方和可执行区域开始的地方
unsigned long argv_len; // 程序参数数量
};
linux_binfmt
结构,不同的可执行文件有不同的格式,每种可执行文件的加载执行由linux_binfmt
负责,Linux支持多种不同的可执行文件格式,因此由多个linux_binfmt实例,这些实例通过链表链接起来,表头为内核全局变量formats
。Linux在执行可执行文件时会遍历该链表,根据不同可执行文件的特征(通常是魔数)来找到合适的linux_binfmt
,调用该实例的方法将可执行文件加载进进程地址空间并执行。每种可执行文件的linux_binfmt
必须首先使用register_binfmt
向内核注册才能被使用。该结构一般包含以下方法:load_binary
: 用于加载普通程序load_shlib
:用于加载共享库,即动态库core_dump
:在程序错误的情况下输出内存转储,用于调试分析。
// filename: 可执行文件名称
// argv: 执行参数
// envp: 环境变量
// regs: 进程用户态寄存器内容
int do_execve(char * filename,
char __user *__user *argv,
char __user *__user *envp,
struct pt_regs * regs)
{
struct linux_binprm *bprm;
struct file *file;
unsigned long env_p;
int retval;
retval = -ENOMEM;
bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
if (!bprm)
goto out_ret;
// 打开要执行的文件
file = open_exec(filename);
retval = PTR_ERR(file);
if (IS_ERR(file))
goto out_kfree;
// 进行处理器间负载均衡,选择一个合适的cpu
// 并且将当前将要执行execve的进程移动到合适cpu上
// 在这里移动,由于程序还没有真正开始执行,
// 因此对内存和缓存影响最小
sched_exec();
bprm->file = file;
bprm->filename = filename;
bprm->interp = filename;
// 初始化linux_binprm结构
// 该函数主要初始化了进程新的内存地址空间,目前进程的
// 内存地址空间和父进程完全一样,因此需要属于自己的mm
// 其中主要设置了新的栈空间,并且如果当前进程有ldt描述符
// 则拷贝到新的mm中
retval = bprm_mm_init(bprm);
if (retval)
goto out_file;
// 参数个数
bprm->argc = count(argv, MAX_ARG_STRINGS);
if ((retval = bprm->argc) < 0)
goto out_mm;
// 环境变量个数
bprm->envc = count(envp, MAX_ARG_STRINGS);
if ((retval = bprm->envc) < 0)
goto out_mm;
// 钩子
retval = security_bprm_alloc(bprm);
if (retval)
goto out;
// 主要是检查二进制文件的可执行全县,然后将二进制
// 文件头部的128字节读取到bprm的buf中
// 该头部用于识别二进制文件格式等其他信息,后面会使用
retval = prepare_binprm(bprm);
if (retval < 0)
goto out;
// 将filename拷贝到bprm的栈中,因为此时bprm->filename
// 已经位于内核空间,因此调用copy_strings_kernel
retval = copy_strings_kernel(1, &bprm->filename, bprm);
if (retval < 0)
goto out;
// 由于前面拷贝了可执行文件名,所以p向下移动了
// 此时exec指向的地方就是程序执行的地方
bprm->exec = bprm->p;
// 从用户空间拷贝环境变量到新的内存空间中
retval = copy_strings(bprm->envc, envp, bprm);
if (retval < 0)
goto out;
env_p = bprm->p;
// 从用户空间拷贝程序参数到内存空间中
retval = copy_strings(bprm->argc, argv, bprm);
if (retval < 0)
goto out;
// 记录参数长度
bprm->argv_len = env_p - bprm->p;
// 加载并执行可执行文件
retval = search_binary_handler(bprm,regs);
if (retval >= 0) {
/* execve success */
free_arg_pages(bprm);
security_bprm_free(bprm);
acct_update_integrals(current);
kfree(bprm);
return retval;
}
out:
free_arg_pages(bprm);
if (bprm->security)
security_bprm_free(bprm);
out_mm:
if (bprm->mm)
mmput (bprm->mm);
out_file:
if (bprm->file) {
allow_write_access(bprm->file);
fput(bprm->file);
}
out_kfree:
kfree(bprm);
out_ret:
return retval;
}
让我们进一步深入进去,看看各个子函数,首先来看bprm_mm_init
函数
int bprm_mm_init(struct linux_binprm *bprm)
{
int err;
struct mm_struct *mm = NULL;
// 申请新的mm结构,一般从slab申请
bprm->mm = mm = mm_alloc();
err = -ENOMEM;
if (!mm)
goto err;
// 一般当前进程的mm结构是执行fork时从父进程拷贝的
// 如果当前进程使用了ldt,那么同样拷贝到新的mm中
err = init_new_context(current, mm);
if (err)
goto err;
// 创建进程新的地址空间
err = __bprm_mm_init(bprm);
if (err)
goto err;
return 0;
err:
if (mm) {
bprm->mm = NULL;
mmdrop(mm);
}
return err;
}
static int __bprm_mm_init(struct linux_binprm *bprm)
{
int err = -ENOMEM;
struct vm_area_struct *vma = NULL;
struct mm_struct *mm = bprm->mm;
// vma标识了进程的虚拟地址空间区域,暂时不用深究
// 这里从slab申请一个新的vma对象
bprm->vma = vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
if (!vma)
goto err;
// 信号量同步
down_write(&mm->mmap_sem);
// 指向进程新的mm结构
vma->vm_mm = mm;
/*
* Place the stack at the largest stack address the architecture
* supports. Later, we'll move this to an appropriate place. We don't
* use STACK_TOP because that can depend on attributes which aren't
* configured yet.
*/
// 一般来说STACK_TOP_MAX的值就是用户空间的最大地址值3G - 1
// 而通常PAGE_SIZE = 4K,因此进程新的栈空间为4K,从用户空间的
// 最高处想下增长
vma->vm_end = STACK_TOP_MAX;
vma->vm_start = vma->vm_end - PAGE_SIZE;
// 设置虚拟地址空间的一些标志和属性
vma->vm_flags = VM_STACK_FLAGS;
vma->vm_page_prot = vm_get_page_prot(vma->vm_flags);
// 将进程新的栈空间放到mm中
err = insert_vm_struct(mm, vma);
if (err) {
up_write(&mm->mmap_sem);
goto err;
}
// 统计信息
mm->stack_vm = mm->total_vm = 1;
// 出临界区
up_write(&mm->mmap_sem);
// 之所以减去一个指针,是因为vma使用的?
bprm->p = vma->vm_end - sizeof(void *);
return 0;
err:
if (vma) {
bprm->vma = NULL;
kmem_cache_free(vm_area_cachep, vma);
}
return err;
}
可以看到,对于一个执行了execve调用的进程,会拥有新的用户栈空间,从最大用户地址开始向下延伸4K。
我们简单看一下searh_binary_handler
函数
int search_binary_handler(struct linux_binprm *bprm,struct pt_regs *regs)
{
int try,retval;
struct linux_binfmt *fmt;
// 钩子
retval = security_bprm_check(bprm);
if (retval)
return retval;
/* kernel module loader fixup */
/* so we don't try to load run modprobe in kernel space. */
set_fs(USER_DS);
// 审计信息,忽略
retval = audit_bprm(bprm);
if (retval)
return retval;
retval = -ENOENT;
// 一共尝试两次
for (try=0; try<2; try++) {
// 临界区读写锁
read_lock(&binfmt_lock);
// 遍历所有的binaryfmt,找到和可执行文件匹配的项去加载
list_for_each_entry(fmt, &formats, lh) {
int (*fn)(struct linux_binprm *, struct pt_regs *) = fmt->load_binary;
if (!fn)
continue;
if (!try_module_get(fmt->module))
continue;
read_unlock(&binfmt_lock);
// load_binary加载可执行文件
retval = fn(bprm, regs);
// 加载成功
if (retval >= 0) {
// 释放fmt,可执行文件
put_binfmt(fmt);
allow_write_access(bprm->file);
if (bprm->file)
fput(bprm->file);
bprm->file = NULL;
// 当前进程已经执行过
current->did_exec = 1;
proc_exec_connector(current);
return retval;
}
// 如果加载失败,则换下一个继续试
read_lock(&binfmt_lock);
put_binfmt(fmt);
if (retval != -ENOEXEC || bprm->mm == NULL)
break;
if (!bprm->file) {
read_unlock(&binfmt_lock);
return retval;
}
}
read_unlock(&binfmt_lock);
if (retval != -ENOEXEC || bprm->mm == NULL) {
break;
#ifdef CONFIG_KMOD
}else{
#define printable(c) (((c)=='\t') || ((c)=='\n') || (0x20<=(c) && (c)<=0x7e))
if (printable(bprm->buf[0]) &&
printable(bprm->buf[1]) &&
printable(bprm->buf[2]) &&
printable(bprm->buf[3]))
break; /* -ENOEXEC */
request_module("binfmt-%04x", *(unsigned short *)(&bprm->buf[2]));
#endif
}
}
return retval;
}
可以看到,最终调用和可执行文件匹配的项去加载可执行文件,如果重试两次仍然找不到则报错返回。通过这一节,对于Linux进程的内存空间映像,你应该有一个大致印象了吧,我们通过一张图来结束本节:
进程退出
前面一次介绍了创建进程、执行进程,一个进程从创建到执行最后退出,是一个完成的生命周期,那么在本节将着重介绍进程退出细节。进程退出一般来说是分为两部分的:首先是进程释放出了task_struct
之外的其他资源,然后给父进程发送信号表明自己死亡,此时进程处于僵尸状态,永远不会被调度执行,但是其占用的资源也永远不会被释放;接着父进程调用了wait4系列系统调用,确认了子进程的死亡,才会释放子进程最后的资源。当然,如果在创建子进程时父进程设置了标志表明自己不需要确认子进程死亡情况,那么子进程退出时就不会给父进程发送信号,而是直接释放自己的所有资源,包括task_struct
。首先看以下do_exit
函数:
fastcall NORET_TYPE void do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
// 和退出流程无关,忽略
profile_task_exit(tsk);
WARN_ON(atomic_read(&tsk->fs_excl));
// 如果处于中断上下文,或者idle进程执行该函数
// 说明内核出现了问题
if (unlikely(in_interrupt()))
panic("Aiee, killing interrupt handler!");
if (unlikely(!tsk->pid))
panic("Attempted to kill the idle task!");
// 如果退出进程被跟踪调试并且设置了PT_TRACE_EXIT
// 那么通知调试者进程,PT_TRACE_EXIT标志表明调试者进程
// 希望被调试进程退出时停止执行,可以看到这里把退出码
// 放到了ptrace_message中,这样调试进程就能够通过PTRACE_GETEVENTMSG
// 获取退出进程的信息(参见kernel/ptrace.c ptrace_request函数)
// 在ptrace_notify中可能会调用schedule。
if (unlikely(current->ptrace & PT_TRACE_EXIT)) {
current->ptrace_message = code;
ptrace_notify((PTRACE_EVENT_EXIT << 8) | SIGTRAP);
}
/*
* We're taking recursive faults here in do_exit. Safest is to just
* leave this task alone and wait for reboot.
*/
// 大概率不会执行
if (unlikely(tsk->flags & PF_EXITING)) {
printk(KERN_ALERT
"Fixing recursive fault but reboot is needed!\n");
/*
* We can do this unlocked here. The futex code uses
* this flag just to verify whether the pi state
* cleanup has been done or not. In the worst case it
* loops once more. We pretend that the cleanup was
* done as there is no way to return. Either the
* OWNER_DIED bit is set by now or we push the blocked
* task into the wait for ever nirwana as well.
*/
tsk->flags |= PF_EXITPIDONE;
if (tsk->io_context)
exit_io_context();
set_current_state(TASK_UNINTERRUPTIBLE);
schedule();
}
// 设置进程标志为PF_EXITING,表明进程正在退出
tsk->flags |= PF_EXITING;
/*
* tsk->flags are checked in the futex code to protect against
* an exiting task cleaning up the robust pi futexes.
*/
// 指令屏障,忽略之
smp_mb();
spin_unlock_wait(&tsk->pi_lock);
// 如果当前处于禁止抢占状态,说明有问题
// 因为没有理由这里会禁止抢占,内核校验
// 大概率不会执行到,忽略之
if (unlikely(in_atomic()))
printk(KERN_INFO "note: %s[%d] exited with preempt_count %d\n",
current->comm, task_pid_nr(current),
preempt_count());
// 更新一些统计信息,如果编译内核时没有启动CONFIG_TASKSTATS则该函数什么也不做
// 否则主要更新退出进程常驻内存使用情况,虚拟内存使用情况以及
// 用户态运行时间和内核台运行时间
acct_update_integrals(tsk);
// 如果进程mm不为空,则更新mm的最大物理页数和最大虚拟页数
if (tsk->mm) {
update_hiwater_rss(tsk->mm);
update_hiwater_vm(tsk->mm);
}
// 递减当前线程总活动线程数量,如果递减完为0则返回true
group_dead = atomic_dec_and_test(&tsk->signal->live);
// 如果当前进程是线程组最后一个活动线程,那么
// 需要做一些额外的工作
if (group_dead) {
// 如果判断当前进程所在线程组的组长就是当前
// pid_namespace的init进程,那么需要向当前命名空间
// 所有的进程都发送SIGKiLL信号,并且由当前进程调用wait4
// 系统调用负责善后
exit_child_reaper(tsk);
// 定时器相关
hrtimer_cancel(&tsk->signal->real_timer);
exit_itimers(tsk->signal);
}
// 统计信息收集,用户态运行时间,内核台运行时间
// 主缺页次数,次缺页次数等,忽略之
acct_collect(code, group_dead);
// 懒得去研究这个配置了,当做没有吧,忽略之
#ifdef CONFIG_FUTEX
if (unlikely(tsk->robust_list))
exit_robust_list(tsk);
#ifdef CONFIG_COMPAT
if (unlikely(tsk->compat_robust_list))
compat_exit_robust_list(tsk);
#endif
#endif
// 审计工作,懒得研究,忽略之
if (group_dead)
tty_audit_exit();
if (unlikely(tsk->audit_context))
audit_free(tsk);
// 设置进程的退出码
tsk->exit_code = code;
// 又是一些统计信息,实在不想看了
taskstats_exit(tsk, group_dead);
// 减少mm_user使用计数
// 先增加mm_count使用计数,然后在mput中
// 又减少了mm_count使用计数,因此总的来说mm_count没变
// 该函数有个非常有意思的细节,我们后面单独说
exit_mm(tsk);
// 不看!!!
if (group_dead)
acct_process();
// 进程信号量
exit_sem(tsk);
// 进程打开的文件
__exit_files(tsk);
// 进程使用的文件系统
__exit_fs(tsk);
// hmmm,忽略
check_stack_usage();
// 进程的thread_info
exit_thread();
// cgroup...虚拟化命名空间相关的东西
cgroup_exit(tsk, 1);
// 可能是密钥环
exit_keys(tsk);
if (group_dead && tsk->signal->leader)
disassociate_ctty(1);
// 忽略之
module_put(task_thread_info(tsk)->exec_domain->module);
if (tsk->binfmt)
module_put(tsk->binfmt->module);
// 驱动相关的一些东西貌似是,忽略之
proc_exit_connector(tsk);
// 比较重要,实际上如果进程在退出时需要给父进程信号,那么在该函数中就会调用schedule
// 也就是说,后面的部分都不会被执行。
exit_notify(tsk);
// 对于非一致性内存生效,算了有点儿麻烦,不说了
#ifdef CONFIG_NUMA
mpol_free(tsk->mempolicy);
tsk->mempolicy = NULL;
#endif
/*
* Make sure we are holding no locks:
*/
// debug用的,忽略
debug_check_no_locks_held(tsk);
/*
* We can do this unlocked here. The futex code uses this flag
* just to verify whether the pi state cleanup has been done
* or not. In the worst case it loops once more.
*/
// 设置当前进程标志,退出成功
tsk->flags |= PF_EXITPIDONE;
if (tsk->io_context)
exit_io_context();
if (tsk->splice_pipe)
__free_pipe_info(tsk->splice_pipe);
// 禁用抢占
preempt_disable();
/* causes final put_task_struct in finish_task_switch(). */
// 设置状态为DEAD并调用schedule(),正常情况下永远不会再返回了,因此schedule永远
// 不会调度DEAD状态的进程执行
tsk->state = TASK_DEAD;
schedule();
BUG();
/* Avoid "noreturn function does return". */
for (;;)
cpu_relax(); /* For when BUG is null */
}
可以看到do_exit
函数主要是做了以下几部分工作
- 向调试进程发信号,调用schedule唤醒调试进程(可能)
- 向当前命名空间所有进程发送KILL信号(可能)
- 乱七八糟的统计工作,审计信息
- 然后就是一堆exit_xx,负责各个子模块:mm、ipc信号、打开的文件、文件系统、thread_info
- 然后就是exit_notify,为退出进程的子进程找到合适的父进程,然后向退出进程的父进程发送信号并且调用schedule(可能)
- 如果exit_notify没有调用schedule,那么后面就是设置状态然后调用schedule.
因为进程涉及到内核的各个子系统,因此进程退出时需要执行的工作也是不少,其中有些模块还并未介绍,因此这里就不详细介绍了,这里主要介绍exit_notify和exit_mm两个函数,其他部分类似ipc、文件相关,等介绍完其他模块后,再回过头来看,就没难度了。
先介绍exit_mm
,为什么介绍mm呢?因为其中一个小操作觉得非常的有趣,想分享一下
static void exit_mm(struct task_struct * tsk)
{
struct mm_struct *mm = tsk->mm;
// 不要被名字误导了,该函数主要是清空缓存的寄存器状态
// 然后如果当前进程创建时使用了vfork,那么此时该进程的父进程
// 还阻塞的vfork_done上,该函数负责执行comeplete,唤醒父进程
mm_release(tsk, mm);
if (!mm)
return;
/*
* Serialize with any possible pending coredump.
* We must hold mmap_sem around checking core_waiters
* and clearing tsk->mm. The core-inducing thread
* will increment core_waiters for each thread in the
* group with ->mm != NULL.
*/
// 信号量
down_read(&mm->mmap_sem);
// core_waiters记录了正在转储进程mm映像的进程数
// 如果有则等着
if (mm->core_waiters) {
up_read(&mm->mmap_sem);
down_write(&mm->mmap_sem);
if (!--mm->core_waiters)
complete(mm->core_startup_done);
up_write(&mm->mmap_sem);
wait_for_completion(&mm->core_done);
down_read(&mm->mmap_sem);
}
// 增加mm_count的计数
// 这里你可能非常奇怪,为什么释放mm,反而要增加计数呢
// 别着急,这里其实非常有趣,我后面会介绍
atomic_inc(&mm->mm_count);
BUG_ON(mm != tsk->active_mm);
/* more a memory barrier than a real lock */
task_lock(tsk);
// 将进程的mm置空,注意,这里进程的
// active_mm还是存在的
tsk->mm = NULL;
up_read(&mm->mmap_sem);
// 进入懒惰tlb模式,因为有可能下一个进程
// 是内核线程
enter_lazy_tlb(mm, current);
/* We don't want this task to be frozen prematurely */
clear_freeze_flag(tsk);
task_unlock(tsk);
// 该函数递减mm_user,如果mm_user = 0则调用mmdrop递减
// mm_count, 如果mm_count为0则将mm_struct返还给slab
// mm_count和mm_user的关系见https://blog.csdn.net/wen0605/article/details/8703337
mmput(mm);
}
现在让我们来说一说exit_mm中非常有趣的地方:为什么明明是exit_mm,但是却增加了mm_count的计数呢?其实有两个原因
- 当前进程正在用的页表目录信息存储在mm_struct中,如果不增加mm_count,那么mmput递减后发现mm_count为0,则会释放mm_struct给slab,该操作也会释放mm中的页目录信息以及pgd。但是其实当前退出进程在此刻以及以后都不会访问用户空间了,因此实际上只需要内核空间。个人理解可以将swagger_pg_dir加载进入cr3,然后释放mm_struct,不过这样的话就会导致tlb刷新等操作,可能开销比较大。而且目前看来内核页表只是作为在fork新进程时作为模版使用,如果让用户进程直接使用内核页表,可能存在问题?
- 第二个作用就设置到进程切换了,可以说,正是由于这里增大了mm_count,在finish_task_switch中才能够仍然访问到当前退出进程的mm_struct,并且在那里该进程的mm_struct和task_struct会被彻底释放,由于进程切换暂时还没介绍,所以这里就不赘述,只是贴一篇文章,等后面介绍完进程调度相关内容后再回过头来看,会更有体会。
- 顺带再说一句,这里之所以开启懒惰tlb模式,是考虑到下一个进程可能是内核线程,因此是无需刷新tlb等操作的。
简单介绍了exit_mm
,下面就着重介绍一下exit_notify
函数,该函数主要作用是为退出进程的子进程重新找到父亲进程,如果需要的话会向退出进程的父进程发送信号并执行schedule函数
static void exit_notify(struct task_struct *tsk)
{
int state;
struct task_struct *t;
struct pid *pgrp;
// 如果当前退出进程有挂起的信号并且SIGNAL_GROUP_EXIT没有置位,并且退出进程所在线程组
// 不为空,说明当前进程有信号待处理并且所在线程组也没有处于退出状态
// 并且线程组不为空,那么需要进行额外操作
// 这种情况发生在选择了当前退出进程作为唤醒者的群体信号和退出系统调用同时发生时的竞争
// 因此在退出之前,该线程应该完成最后的使命,唤醒线程组的所有线程,保证组内有的进程
// 能够获取到挂起的信号
if (signal_pending(tsk) && !(tsk->signal->flags & SIGNAL_GROUP_EXIT)
&& !thread_group_empty(tsk)) {
/*
* This occurs when there was a race between our exit
* syscall and a group signal choosing us as the one to
* wake up. It could be that we are the only thread
* alerted to check for pending signals, but another thread
* should be woken now to take the signal since we will not.
* Now we'll wake all the threads in the group just to make
* sure someone gets all the pending signals.
*/
spin_lock_irq(&tsk->sighand->siglock);
// 遍历线程组
for (t = next_thread(tsk); t != tsk; t = next_thread(t))
// 如果当前进程没有设置TIF_SIGPENDING并且没有处于退出状态
// 那么需要当前进程对该进程进行唤醒,而对于那些处于退出状态的进程来水
// 他们本身就不需要处理信号;对于设置了TIF_SIGPENDING标志的进程来说
// 它们肯定会被唤醒并且处理相应信号,不需要这里手动唤醒
// TIF_SIGPENDING标志表明进程有待处理的信号
if (!signal_pending(t) && !(t->flags & PF_EXITING))
// 话说这个函数里面重复设置了SIGPENDING标志,然后我翻看了
// 最新的Linux版本,发现还有这个问题,果断提了个patch过去
// 这是后话了
recalc_sigpending_and_wake(t);
spin_unlock_irq(&tsk->sighand->siglock);
}
/*
* This does two things:
*
* A. Make init inherit all the child processes
* B. Check to see if any process groups have become orphaned
* as a result of our exiting, and if they have any stopped
* jobs, send them a SIGHUP and then a SIGCONT. (POSIX 3.2.2.2)
*/
// 重要逻辑,后面说
forget_original_parent(tsk);
exit_task_namespaces(tsk);
write_lock_irq(&tasklist_lock);
/*
* Check to see if any process groups have become orphaned
* as a result of our exiting, and if they have any stopped
* jobs, send them a SIGHUP and then a SIGCONT. (POSIX 3.2.2.2)
*
* Case i: Our father is in a different pgrp than we are
* and we were the only connection outside, so our pgrp
* is about to become orphaned.
*/
// 获取退出进程的real_parent,也就是fork出
// 该进程的父进程
t = tsk->real_parent;
// 获取退出进程所在的进程组
pgrp = task_pgrp(tsk);
// 判断,如果当前进程是所在进程组和外界联系的唯一进程
// 也就是说当前进程退出之后当前进程所在的进程组就变成孤儿进程组了
// 并且进程组里面有STOP状态的进程,则向进程组中的所有进程
// 发送SIGHUP和SIGCONT两次信号,SIGHUP默认会杀死进程
// 除非进程自定义了处理操作
if ((task_pgrp(t) != pgrp) &&
(task_session(t) == task_session(tsk)) &&
will_become_orphaned_pgrp(pgrp, tsk) &&
has_stopped_jobs(pgrp)) {
__kill_pgrp_info(SIGHUP, SEND_SIG_PRIV, pgrp);
__kill_pgrp_info(SIGCONT, SEND_SIG_PRIV, pgrp);
}
/* Let father know we died
*
* Thread signals are configurable, but you aren't going to use
* that to send signals to arbitary processes.
* That stops right now.
*
* If the parent exec id doesn't match the exec id we saved
* when we started then we know the parent has changed security
* domain.
*
* If our self_exec id doesn't match our parent_exec_id then
* we have changed execution domain as these two values started
* the same after a fork.
*/
// 这块不太理解原因,不过不影响
if (tsk->exit_signal != SIGCHLD && tsk->exit_signal != -1 &&
( tsk->parent_exec_id != t->self_exec_id ||
tsk->self_exec_id != tsk->parent_exec_id)
&& !capable(CAP_KILL))
tsk->exit_signal = SIGCHLD;
/* If something other than our normal parent is ptracing us, then
* send it a SIGCHLD instead of honoring exit_signal. exit_signal
* only has special meaning to our real parent.
*/
// exit_signal == -1表示父进程不关心子进程的退出
// 这里不等于-1(说明父进程需要查看子进程的退出信息,也就是说子进程退出时不能释放task_struct结构)
// 并且退出进程时线程组的最后一个进程
if (tsk->exit_signal != -1 && thread_group_empty(tsk)) {
// 如果tsk->parent 不等于 tsk->real_parent, 说明有进程正在跟踪调试当前进程
// 一次向调试进程发送SIGCHLD信号,否则向fork出当前进程的父进程发送指定的退出
// 信号
int signal = tsk->parent == tsk->real_parent ? tsk->exit_signal : SIGCHLD;
do_notify_parent(tsk, signal);
} else if (tsk->ptrace) {
do_notify_parent(tsk, SIGCHLD);
}
// 设置进程状态为僵尸状态
state = EXIT_ZOMBIE;
// 如果父进程表明不需要子进程退出后给它发信号并且也没有调试进程
// 跟踪当前进程,那么当前进程就需要在退出前自己释放task_struct等资源
// 因此这里直接把状态设置为死亡状态
if (tsk->exit_signal == -1 && likely(!tsk->ptrace))
state = EXIT_DEAD;
tsk->exit_state = state;
// 如果当前进程是线程组的组长并且线程组所有的线程都
// 被杀死了,那么唤醒group_exit_task
// 该部分涉及到execve函数中binfmt的load_binary函数
// 所以前面没有细说,参考一下这篇博客吧
// http://blog.chinaunix.net/uid-30126070-id-5073809.html
if (thread_group_leader(tsk) &&
tsk->signal->notify_count < 0 &&
tsk->signal->group_exit_task)
wake_up_process(tsk->signal->group_exit_task);
write_unlock_irq(&tasklist_lock);
/* If the process is dead, release it - nobody will wait for it */
// 走到这里说明,父进程不需要子进程的退出信号通知
// 并且也没有进程在调试追踪该进程,那么该进程在退出时需要自己负责
// 释放剩下的所有资源(除了mm_struct,因此随后的schedule还会使用到,并且由
// schedule函数选择的下一个进程负责释放)
if (state == EXIT_DEAD)
// 单独说
release_task(tsk);
}
介绍到这里,整个退出流程就只剩下forget_original_parent
和release_task
两个函数没有介绍了,接着来吧,从forget_original_parent
开始
static void forget_original_parent(struct task_struct *father)
{
struct task_struct *p, *n, *reaper = father;
struct list_head ptrace_dead;
INIT_LIST_HEAD(&ptrace_dead);
write_lock_irq(&tasklist_lock);
// 为退出进程的子进程门寻找新的父进程,首先是在退出进程
// 的线程组里面找,如果找不到,那么让当前进程所在命名空间的
// init进程作为新的父进程
do {
reaper = next_thread(reaper);
if (reaper == father) {
reaper = task_child_reaper(father);
break;
}
} while (reaper->flags & PF_EXITING);
/*
* There are only two places where our children can be:
*
* - in our child list
* - in our ptraced child list
*
* Search them and reparent children.
*/
// 遍历退出进程所有的子进程
list_for_each_entry_safe(p, n, &father->children, sibling) {
int ptrace;
ptrace = p->ptrace;
/* if father isn't the real parent, then ptrace must be enabled */
// 对于当前子进程p,如果他位于退出进程的子进程链表中
// 那么它的parent肯定是当前进程,此时又有两种情况
// 1. 退出进程是它的real_parent,即fork出它的进程
// 2. 退出进程是它的parent,即跟踪调试它的进程
// 如果两个都不是,那就是有bug
BUG_ON(father != p->real_parent && !ptrace);
// 如果是fork它的进程,那么为子进程重新寻找新的父进程
if (father == p->real_parent) {
/* reparent with a reaper, real father it's us */
p->real_parent = reaper;
reparent_thread(p, father, 0);
} else {
/* reparent ptraced task to its real parent */
// 到这里说明当前进程是被退出进程跟踪调试的进程
// 因此该进程的parent是将要退出调试进程,并且在退出进程的链表中
// 那么这里做的就是将该进程从退出进程的链表中移出
// 并且重新执行该进程的parent进程为real_parent
__ptrace_unlink (p);
// 如果当前进程处于僵尸状态,并且父进程需要该进程的退出信息
// 并且该进程所在进程组已经为空,则向父进程发送退出信号唤醒之
if (p->exit_state == EXIT_ZOMBIE && p->exit_signal != -1 &&
thread_group_empty(p))
do_notify_parent(p, p->exit_signal);
}
/*
* if the ptraced child is a zombie with exit_signal == -1
* we must collect it before we exit, or it will remain
* zombie forever since we prevented it from self-reap itself
* while it was being traced by us, to be able to see it in wait4.
*/
// 如果当前进程被即将退出进程跟踪,并且处于EXIT_ZOMBIE状态并且其父进程也不关心
// 它的死活,那么由退出进程负责释放该进程的剩余资源
if (unlikely(ptrace && p->exit_state == EXIT_ZOMBIE && p->exit_signal == -1))
list_add(&p->ptrace_list, &ptrace_dead);
}
// 遍历当前进程的调试链表,把所有被当前进程跟踪的调试进程添加
// 到新的进程的ptrace_child链表中
// 一般是退出进程的兄弟进程或者是当前命名空间的init进程
list_for_each_entry_safe(p, n, &father->ptrace_children, ptrace_list) {
p->real_parent = reaper;
reparent_thread(p, father, 1);
}
write_unlock_irq(&tasklist_lock);
// 到这里要求没有任何进程和当前退出进程关联
BUG_ON(!list_empty(&father->children));
BUG_ON(!list_empty(&father->ptrace_children));
// 就像上面说的,释放僵尸进程的剩余资源
list_for_each_entry_safe(p, n, &ptrace_dead, ptrace_list) {
list_del_init(&p->ptrace_list);
release_task(p);
}
}
介绍完了forget_original_parent
,可以发现函数主要是负责为所有和当前进程相关的子进程和调试子进程找到一个新的归宿,其中少不了还有一些其他操作。下面再介绍最后一个函数release_task
。可能到这里你已经有点懵了,但是别慌,后面我会把整个退出流程完整的再捋一遍
void release_task(struct task_struct * p)
{
struct task_struct *leader;
int zap_leader;
repeat:
// 减少用户拥有的进程数
atomic_dec(&p->user->processes);
proc_flush_task(p);
write_lock_irq(&tasklist_lock);
// 如果当前进程被跟踪调试,则将当前进程
// 从调试进程的ptrac_childre中移除
// 添加到当前进程real_parent所在的children链表中
// 并且修改parent为real_parent
ptrace_unlink(p);
// 按道理来说执行完do_exit的进程这两个链表应该都为空,若不为空说明有问题
BUG_ON(!list_empty(&p->ptrace_list) || !list_empty(&p->ptrace_children));
// 清除信号相关结构
__exit_signal(p);
/*
* If we are the last non-leader member of the thread
* group, and the leader is zombie, then notify the
* group leader's parent process. (if it wants notification.)
*/
zap_leader = 0;
leader = p->group_leader;
if (leader != p && thread_group_empty(leader) && leader->exit_state == EXIT_ZOMBIE) {
BUG_ON(leader->exit_signal == -1);
do_notify_parent(leader, leader->exit_signal);
/*
* If we were the last child thread and the leader has
* exited already, and the leader's parent ignores SIGCHLD,
* then we are the one who should release the leader.
*
* do_notify_parent() will have marked it self-reaping in
* that case.
*/
zap_leader = (leader->exit_signal == -1);
}
write_unlock_irq(&tasklist_lock);
// 体系结构相关,忽略
release_thread(p);
// 释放task_struct以及和其相关的一些信息
call_rcu(&p->rcu, delayed_put_task_struct);
// 像上面英文注释的那样,这里还需要释放组长线程的task_struct结构
p = leader;
if (unlikely(zap_leader))
goto repeat;
}
到这里进程退出的故事就结束了,通过介绍可以发现:如果父进程创建进程时表明不需要关注子进程退出信息,那么子进程就会在退出时自己负责清楚自己占用的task_struct及其相关资源;如果父进程需要关注子进程退出信息,那么子进程退出时不能直接清理task_struct、signal等信息,需要等父进程调用wait4后才能清除;但是如果父进程表明需要关注子进程的退出信息,但是却忘了调用wait4,那么就会产生僵尸进程,也就是已经退出的子进程还占用着资源。如果父进程退出了,那么僵尸进程其实会被init进程慢慢清除,但是如果父进程一直不退出,并且也不调用wait系统调用,还大量创建子进程,那么就会出现问题。其实对于现代计算机来说,占用task_struct、signal等资源所耗费的内存空间并不多,主要的一点在与一个用户创建的进程数量是有限制的、PID也是有限的,如果僵尸进程过多,那么用户可能无法创建新进程!对于wait系列的系统调用不打算在这里介绍了,一是因为这一块属于signal的范畴,二是因为到目前为止文章已经过长了。
好了,我们梳理一下进程退出时的整体逻辑(后面我们用当前进程来代指退出进程)
- 判断是否在中断上下文或者退出的进程是idle(0)号进程,如果是说明有问题,直接panic。中断上下文要求及断的时间,肯定是不能执行这种操作的,而idle进程的生命周期是整个系统的生命周期,自然也是不能中途退出的,因此这种属于系统bug
- 如果当前进程被跟踪调试并且设置了
PT_TRACE_EXIT
,那么需要向调试进程发送信号,告知当前进程退出码,这里可能会调用schedule()函数,这样的话当前进程就会停在这里,PT_TRACE_EXIT标志表明调试进程希望当前进程在退出前向其发送信号并停止 - 判断当前是否处于禁止抢占状态,如果是则打印消息;按道理来说此时不应该禁止抢占。
- 更新一些统计信息,比如用户态,内核态运行时间,更新当前进程的最大物理页数和最大虚拟页数等(这些需要根据编译内核时是否配置相关项而决定是否进行统计)
- 判断当前进程是否是所在线程组中最后一个活动进程,如果是那么当前进程需要做一些额外工作:
- 如果当前进程所在线程组的组长就是当前线程所在pid命名空间的init进程,那么此时需要向当前命名空间的所有进程发送KILL信号。
- 清除定时器相关信息
- 设置当前进程的退出码
task_struct.exit_code
- mm结构处理:清除当前进程对其mm_struct的引用并且进入懒惰tlb模式(其中如果有其他线程在dump mm信息则等待)
- ipc处理:清除和当前进程有关的ipc信息
- 文件处理:清除当前进程打开的文件信息
- 文件系统处理:清除当前进程使用的文件系统信息
- 接下来就是调用
exit_notify
完成以下工作- 如果当前进程有挂起的信号并且当前进程所在线程组不处于退出状态,并且有其他的线程存在,那么需要唤醒线程组中每个不处于退出状态且没有挂起信号的进程,让它们进行信号处理。这样做主要是为了防止这样一种情况:当前进程作为整个线程组的唤醒者接收了发送给线程组的信号,但是同时它执行了exit系统调用将要退出,如果当前进程直接退出,那么会造成
不处于退出状态且没有挂起信号
这类进程的信号丢失,因此当前进程需要先完成最后的任务 - 调用
forget_original_parent
为当前进程的子进程和当前进程跟踪调试的子进程(下面统称子进程,除非在需要的时候会区分开来)做善后工作:- 为子进程们寻找新的父进程:遍历当前进程所在的线程组,如果找到一个不处于退出状态的,那么这个进程就是其子进程们的新父进程;如果一个都没找到,那么选当前进程所在命名空间的init进程作为新的父进程
- 遍历子进程链表(
task_struct.children
):对于每一个子进程,判断该子进程是由当前进程fork创建出来的(即real_parent指向当前进程),还是被当前进程跟踪调试的(即parent指向当前进程)- 如果是前者,则设置子进程的real_parent为
11.2.1
步找到的新进程,并且将parent
也指向新进程,把子进程从当前进程的子进程链表移除,加入到新进程的子进程链表中(children
),并且取消子进程的跟踪状态(如果设置了的话,说明当前进程既是子进程的fork进程,也是跟踪进程) - 如果是后者,则将子进程从当前进程的跟踪子进程链表(ptrace_children)中移除,并且将进程的parent指向real_parent,最后加入到real_parent的子进程链表中。这时候如果发现该子进程处于僵尸状态并且父进程需要关注子进程退出状态(exit_signal ≠ -1)并且子进程呢所在线程组没有别的子进程了,那么需要向parent(也就是real_parent)发送信号
- 如果子进程被当前进程跟踪并且处于僵尸状态,但是父进程不关注子进程的退出情况(也就是说不会调用wait并且回收子进程的最后资源),那么当前进程需要负责释放这些子进程的task_struct以及signal等资源
- 如果是前者,则设置子进程的real_parent为
- 遍历调试子进程链表(ptrace_childre),重新设置子进程的real_parent并且将该进程加入到real_parent的调试子进程链表中
- 需要注意的是,对于上面
2和3
步骤中遍历到的每个子进程,如果它们的新父进程不是和当前进程在一个进程组,并且子进程不被跟踪、处于僵尸状态、exit_signal ≠ -1,那么此时也需要向新的父进程发送signal消息告知新的父进程其已经死亡;另外,如果当前进程退出后其子进程会变成僵尸进程组,那么需要向进程组中的每个进程发送HUP和CONT信号。
- 上面步骤判断了当前进程退出后子进程组是否是僵尸进程并且进行一些处理工作,这里则判断当前进程退出后当前进程所在进程组是否会成为僵尸进程组,如果是则和前面一样的处理
- 如果当前进程exit_signal ≠ -1,并且当前进程所在线程组为空,则需要向parent指向的进程发送信号(如果parent指向调试进程,那么只发送SIGCHLD信号,如果指向real_parent则发送exit_signal指定的信号)
- 如果当前进程exit_signal == -1并且没有被其他进程跟踪调试,那么设置进程状态为DEAD,否则设置为ZOMBIE(进程有两个状态,一个是state一个是exit_state)
- 如果当前进程时所在线程组的leader并且所有的其他进程都已经退出,并且
group_exit_task
不为空,说明进程执行execve系统调用时发现进程所在线程组还有存活的线程,因此向所有这些线程发送KILL信号,并且等待线程结束;这里说明已经结束了,因此唤醒前面执行execve的进程 - 如果进程状态为DEAD,则需要当前进程自己释放资源(task_struct以及信号等)
- 如果当前进程有挂起的信号并且当前进程所在线程组不处于退出状态,并且有其他的线程存在,那么需要唤醒线程组中每个不处于退出状态且没有挂起信号的进程,让它们进行信号处理。这样做主要是为了防止这样一种情况:当前进程作为整个线程组的唤醒者接收了发送给线程组的信号,但是同时它执行了exit系统调用将要退出,如果当前进程直接退出,那么会造成
- 禁止内核抢占
- 修改进程状态为DEAD(注意这里只是设置了state,exit_state还没有变)
- 调用schedule(),选择下一个可执行进程
- 下一个可执行进程切换完毕后执行
finish_task_switch
中,会调用put_task_struct函数将前一个退出进程的进程引用计数-1,但是回头看一下fork函数就可以发现,进程在初始化时,设置引用计数为2,因此如果进程的exit_state是ZOMBIE,那么此时task_struct仍然没有被释放,实际上会在do_wait中由父进程释放一次后,引用计数变为0,此时才会最终被释放
小节
本来打算把进程调度和进程切换相关的部分也在这一节介绍完毕的,但是现在看来是不行了..因此目前已经写了太多了,好了,就把进程调度和进程切换放到下一篇文章吧。总结一下,本文主要介绍了Linux的命名空间,简单了解了Linux命名空间如何实现资源隔离,以及如何支持docker等虚拟化技术,并且详细的介绍了PID命名空间;紧接着介绍了进程创建、内核线程、进程执行以及进程退出四方面内容,包含了进程的大部分知识。不过由于进程涉及到系统的各个方面,和多个子系统都有所联系,比如内存、ipc、信号、文件系统等,有关这些模块的介绍实际上只是浅尝辄止,并没有深入进入。等后面介绍了相应模块之后,再回过头来看这些吧。