文章目录
一、概述
进程就是处于执行期的程序,通常包括内容:正文段、数据段、打开的文件、挂起的信号、内核内部数据、处理器状态,内存映射的地址空间、执行线程等。
线程是进程中活动的对象,每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。对于 Linux 来说,线程就是一种特殊的进程。
二、进程管理
2.1 进程描述符
内核把进程放在任务队列(一个双向循环链表中),链表的每一项,都是一个 task_struct 成为进程描述符号,也就是进程控制块。包含一个具体进程的所有信息。大约有 1.7 KB 的大小。
struct task_struct {
struct thread_info thread_info;//处理器特有数据
volatile long state; //进程状态
void *stack; //进程内核栈地址
refcount_t usage; //进程使用计数
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;//采用EDF算法调度实时进程的调度实体
struct sched_info sched_info;//用于调度器统计进程的运行信息
struct list_head tasks;//所有进程的链表
struct mm_struct *mm; //指向进程内存结构
struct mm_struct *active_mm;
pid_t pid; //进程id
struct task_struct __rcu *parent;//指向其父进程
struct list_head children; //链表中的所有元素都是它的子进程
struct list_head sibling; //用于把当前进程插入到兄弟链表中
struct task_struct *group_leader;//指向其所在进程组的领头进程
u64 utime; //用于记录进程在用户态下所经过的节拍数
u64 stime; //用于记录进程在内核态下所经过的节拍数
u64 gtime; //用于记录作为虚拟机进程所经过的节拍数
unsigned long min_flt;//缺页统计
unsigned long maj_flt;
struct fs_struct *fs; //进程相关的文件系统信息
struct files_struct *files;//进程打开的所有文件
struct vm_struct *stack_vm_area;//内核栈的内存区
};
在寄存器较少的体系结构中,一般将当前的 task_struct 放在栈尾部,保存在一个 thread_info 中,通过栈顶指针和偏移量快速获取。
struct thread_info {
struct task_struct *task; //当前进程描述符
void *dump_exec_domain; //备份可执行领域
unsigned long flags; //标志
int preempt_count; //可抢占计数
unsigned long tp_value;
mm_segment_t addr_limit; //地址的界限
struct restart_block restart_block; //信号的实现
struct pt_regs *regs;
unsigned int cpu; //标识当前cpu
};
另外,Unix 进程都存在一个明显的继承关系,所有的进程都是 PID 为 1 的 init 进程的后代。
进程在系统启动的最后阶段启动 init 进程,该进程读取系统的初始化脚本,并执行其他的相关程序,最终完成整个系统的启动。
进程描述符也维护了进程的继承关系,保存了父进程的 ID 以及子进程的 list
struct task_struct __rcu *parent; //父进程引用
struct list_head children; //子进程的链表
struct list_head sibling; //兄弟进程,也就是相同父进程的进程
struct task_struct *group_leader; //进程组的组长
2.2 进程地址空间
Linux 会使用一个 mm_struct 的结构体来保存进程的地址空间。在复制(创建)进程的时候,同样会创建 mm_struct 的结构。
struct mm_struct {
struct vm_area_struct *mmap; //虚拟地址区间链表VMAs
struct rb_root mm_rb; //组织vm_area_struct结构的红黑树的根
unsigned long task_size; //进程虚拟地址空间大小
pgd_t * pgd; //指向MMU页表
atomic_t mm_users; //多个进程共享这个mm_struct
atomic_t mm_count; //mm_struct结构本身计数
atomic_long_t pgtables_bytes;//页表占用了多个页
int map_count; //多少个VMA
spinlock_t page_table_lock; //保护页表的自旋锁
struct list_head mmlist; //挂入mm_struct结构的链表
//进程应用程序代码开始、结束地址,应用程序数据的开始、结束地址
unsigned long start_code, end_code, start_data, end_data;
//进程应用程序堆区的开始、当前地址、栈开始地址
unsigned long start_brk, brk, start_stack;
//进程应用程序参数区开始、结束地址
unsigned long arg_start, arg_end, env_start, env_end;
};
2.3 进程状态
进程中的 _state 描述了进程状态,Linux 的进程状态和传统操作系统五状态、三状态模型有一点区别。
系统中的每个进程都处于这五个状态之一,可以使用 ps -ef 或者 ps aux 查看进程状态。
- TASK_RUNNING:运行态或者就绪态,唯一在用户空间或者内核空间执行的状态,对应 stat 的 R 状态。
- TASK_INTERRUPTIBLE:可中断状态,浅度睡眠态,一般是主动进入睡眠等待某些条件达成,可以响应信号,而唤醒进入运行,对应查询的状态 S。
- TASK_UNINTERRUPTIBLE:不可中断态,深度睡眠的状态,不会响应信号,也就是说,你发送一个 kill 的信号也不会将进程杀死。对应状态 D。
- (TASK_STOPPED 或 TASK_TRACED):表示进程被停止(往往是因为受到信号)或者因为被其他进程追踪而暂停,等待被追踪进程的操作,对应状态 T。
- TASK_ZOMBIE:僵尸态,子进程运行结束,父进程没有感知以及没有通过 wait 函数回收子进程资源,子进程游离在系统成为僵尸进程,对应状态 Z。
内核通过通过 set_current_state 方法设置进程的状态。
#define set_current_state(state_value) \
do { \
debug_normal_state_change((state_value)); \
smp_store_mb(current->__state, (state_value)); \
} while (0)
2.4 进程创建
很多系统对于进程的创建提供了 spawn 的机制,也就是产生进程,包括,分配地址空间, 载入可运行文件,然后执行。而 Unix 又采用了不同的方式,将上述步骤分成 fork() 和 exec() 两部分,fork 通过拷贝父进程来创建一个子进程,而 exec 则读取可执行文件,并载入地址空间开始运行。
另外,为了 fork 之后快速的 exec,Unix 还提供了写时复制的机制,就是子进程fork父进程的时候,不会拷贝一个完全一样的副本,而是拷贝以及修改一些必要信息,共享其余的地址空间,并把空间的权限设置为只读,一旦有进程要进行修改操作,就会引发异常,此时才会拷贝一份需要修改的地址空间,通常以页为单位,这样就可以节省很多拷贝的空间以及时间。Redis 的 RDB 持久化就是使用这种机制的很好案例。
fork()
fork 在父进程调用一次,父子进程各返回一次,父进程返回子进程 ID,子进程返回 0,内核通过返回值的不同来区分父子进程从而执行不同的任务。
Linux 使用系统调用 clone() 来实现 fork() ,拷贝一个子进程大部分内容都由 copy_process() 完成:
- 1)首先会通过 dup_task_struct 备份当前进程,为新进程创建一个内核栈、thread info 以及 task struct,此时和父进程是完全相同的。
- 2)然后就通过一些数据校验,将子进程和父进程区分,为子进程的数据清零或者初始化。
- 3)根据 clone_flag 的参数标志,拷贝或共享打开的文件描述符、信号处理函数、进程地址空间、命名空间等。
- 4)最后,返回一个指向子进程的指针。
fork 的子进程继承父进程的如下参数:
子进程不继承父进程的两者区别如下:
fork 主要用于以下两种情况:
- 类似于 Reactor 模型,父进程复制一个子进程执行不同的代码段,例如网络服务进程,父进程接受连接之后,交给子进程继续执行具体业务,而父进程继续监听连接。
- 父进程要执行一个不同的程序,例如 shell 终端需要执行别的程序从而复制一个子进程,立即调用 exec。
vfork()
vfork 相对于 fork 的区别是,是否对父进程的页表进行复制,vfork 不能向地址空间中写入,并且保证父进程会在子进程 exec 或者 exit 后才会运行。为的是优化运行的效率,但是父进程依赖子进程的运行,可能导致死锁,以及现在写时复制的引入,所以最好不调用 vfork。
2.5 线程实现
从 Linux 内核的角度来说,它没有线程的概念,它也有唯一隶属于它的 task_struct ,所以仅仅被视为一个和其他进程共享某些资源的特殊进程。
对比于其他对于线程有特殊实现的操作系统,需要维护一个进程空间,再维护指向该进程包含的线程,还要维护线程自己的结构。显然,Linux 只需要维护几个进程,并且制定他们共享哪些资源,实现的更高雅。
线程的创建也使用 clone() 系统调用来复制线程,只不过传入的参数有所不同,通过传入的参数来指明需要共享的资源。
创建线程使用:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
fork() 使用:
clone(SIGCHLD, 0);
vfork() 使用:
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
每个参数表达的意思如下:
内核线程
内核需要在后台执行一些操作,这种任务就可以通过内核线程完成。内核线程也有一个存储信息的 task_struct。
内核线程和普通线程的区别在于,内核线程没有独立的地址空间,只在内核空间运行。
主要通过 kthread.h 中的 kthread_create、kthread_run 和 kthread_stop 来控制内核线程的创建,运行以及停止。
2.6 进程终结
进程的终结一般使用 kernel/exit.c 下的 do_exit 函数来实现,源码大致如下:
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
// 省略一些校验和特殊处理
// 将 task_struct 中的标志信号设置为 PF_EXITING
exit_signals(tsk); /* sets PF_EXITING */
// 一些需要特殊退出的情况
if (tsk->mm)
sync_mm_rss(tsk->mm);
// 输出记账信息
acct_update_integrals(tsk);
group_dead = atomic_dec_and_test(&tsk->signal->live);
// 进程组消亡的情况做一些特殊处理
if (group_dead) {
if (unlikely(is_global_init(tsk)))
panic("Attempted to kill init! exitcode=0x%08x\n",
tsk->signal->group_exit_code ?: (int)code);
#ifdef CONFIG_POSIX_TIMERS
// 删除内核定时器,以及取消一些定时处理程序
hrtimer_cancel(&tsk->signal->real_timer);
exit_itimers(tsk->signal);
#endif
if (tsk->mm)
setmax_mm_hiwater_rss(&tsk->signal->maxrss, tsk->mm);
}
acct_collect(code, group_dead);
if (group_dead)
tty_audit_exit();
audit_free(tsk);
// 把 exit_code 置为参数传入的 code ,表示该进程结束
tsk->exit_code = code;
taskstats_exit(tsk, group_dead);
// 释放占用的 mm_struct (mm_struct 指的是进程的虚拟地址空间)
exit_mm();
if (group_dead)
acct_process();
trace_sched_process_exit(tsk);
// 退出IPC信号等待
exit_sem(tsk);
// 释放 shm(shm 是基于内存的临时文件系统) 存储段
exit_shm(tsk);
// 释放递减文件描述符
exit_files(tsk);
// 递减文件系统数据的引用计数,变成 0 的话可以释放,上同
exit_fs(tsk);
if (group_dead)
disassociate_ctty(1);
// 释放任务的命名空间
exit_task_namespaces(tsk);
// 释放任务
exit_task_work(tsk);
// 释放该进程的线程
exit_thread(tsk);
// 做一些通知
perf_event_exit_task(tsk);
sched_autogroup_exit_task(tsk);
cgroup_exit(tsk);
flush_ptrace_hw_breakpoint(tsk);
exit_tasks_rcu_start();
// 向父进程发送信号,给子进程重新找养父,
// 并设置进程状态为 EXIT_ZOMBIE 也就是僵尸进程
exit_notify(tsk, group_dead);
proc_exit_connector(tsk);
mpol_put_task_policy(tsk);
// 省略最后的一些校验和回收
// 最后会调用 schedule() 切换其他进程运行
/**
* 至此,与该进程有关的信息已经被全被释放,
* 但是还占有task_struct、thread_info 和内核栈,
* 需要做的就是通知父进程,父进程检索到消息,就来告诉内核这些是无用内容,可以被回收。
*/
}
子进程结束退出之后,父进程就可以通过 wait 或者 waitpid 来等待回收子进程资源,wait 会阻塞等待一直到第一个退出的进程,而 waitpid 会等待制定 pid 进程,不会阻塞。
wait 函数还是使用系统调用 wait4 来实现,最终需要释放文件描述符时,release_task 函数会被调用。
void release_task(struct task_struct *p)
{
struct task_struct *leader;
struct pid *thread_pid;
int zap_leader;
repeat:
rcu_read_lock();
dec_rlimit_ucounts(task_ucounts(p), UCOUNT_RLIMIT_NPROC, 1);
rcu_read_unlock();
cgroup_release(p);
// 加锁
write_lock_irq(&tasklist_lock);
ptrace_release_task(p);
// 获取进程的id
thread_pid = get_pid(p->thread_pid);
// 从 pidhash 以及任务列表中删除该进程
// 释放所有剩余资源,并进行记录
__exit_signal(p);
zap_leader = 0;
leader = p->group_leader;
// 如果该进程是进程组的最后一个进程,则通知进程组 leader 进程的父进程回收资源
if (leader != p && thread_group_empty(leader)
&& leader->exit_state == EXIT_ZOMBIE) {
zap_leader = do_notify_parent(leader, leader->exit_signal);
if (zap_leader)
leader->exit_state = EXIT_DEAD;
}
write_unlock_irq(&tasklist_lock);
seccomp_filter_release(p);
proc_flush_pid(thread_pid);
put_pid(thread_pid);
// 释放线程
release_thread(p);
// 释放内核栈、thread_info 所占的页以及 task_struct 所占的 slab 高速缓存
// 至此,子进程的所有资源都被释放
put_task_struct_rcu_user(p);
p = leader;
if (unlikely(zap_leader))
goto repeat;
}
总结过程如下:
另外,如果父进程在子进程之前退出,就会通过 exit_notify 找寻别的父进程,为同进程组的进程或者 init 进程,来保证僵尸进程不会一直游离在系统内浪费资源,找到父进程对资源进行回收。
三、进程调度
进程调度算法就不多赘述了。
3.1 Linux 进程调度
首先,Linux 会有一个进程调度的实体,也就是 sched_entity,是 task_struct 的一个属性,主要记录的是调度相关的信息:
struct sched_entity {
struct load_weight load;//表示当前调度实体的权重
struct rb_node run_node;//红黑树的数据节点
struct list_head group_node;// 链表节点,被链接到 percpu 的 rq->cfs_tasks
unsigned int on_rq; //当前调度实体是否在就绪队列上
u64 exec_start;//当前实体上次被调度执行的时间
u64 sum_exec_runtime;//当前实体总执行时间
u64 prev_sum_exec_runtime;//截止到上次统计,进程执行的时间
u64 vruntime;//当前实体的虚拟时间
u64 nr_migrations;//实体执行迁移的次数
struct sched_statistics statistics;//统计信息包含进程的睡眠统计、等待延迟统计、CPU迁移统计、唤醒统计等。
#ifdef CONFIG_FAIR_GROUP_SCHED
int depth;// 表示当前实体处于调度组中的深度
struct sched_entity *parent;//指向父级调度实体
struct cfs_rq *cfs_rq;//当前调度实体属于的 cfs_rq.
struct cfs_rq *my_q;
#endif
#ifdef CONFIG_SMP
struct sched_avg avg ;// 记录当前实体对于CPU的负载
#endif
};
Linux 调度器是以模块方式提供的,这样可以允许不同类型的进程有针对性的选择调度算法。
这种模块化结构被称为调度器类,它允许多种不同的可动态添加的调度算法并存,调度属于自己范畴的进程。每个调度器会有一个优先级,根据优先级选择出调度器从而去调度它范畴内的进程。
Linux 针对普通进程的调度类采用的是完全公平调度(CFS),大致思想是:
允许每个进程运行一段时间,循环轮转,选择运行最少的进程作为下一个运行进程。
具体实现分为了四个部分:
时间记账
内核需要维护一个进程运行时间的记账,以及维护一个加权后的虚拟时间 vruntime ,通过这些来计算已经运行了多久以及还需要运行多久。
进程选择
选择根据优先级计算出的 vruntime (虚拟运行时间)最少的进程。
内核会将所有的存储进程时间记账的结构体存储在一个红黑树中。
调度器入口
调度器的使用入口,也就是进行进程调度的入口,是 kernel/sched.c 下的 schedule() 函数,最主要做的一件事就是,pick_next_task 选择下一个运行的任务,然后执行。
因为Linux 可以使用不同的调度器进行调度,所以每个调度器都会实现 pick_next_task 方法来的得到下一个任务,所以根据优先级得到下一个调度器就可以得到下一个任务。
睡眠与唤醒
为了防止有些不可执行的进程被调度,所以就需要等待队列的加入,内核会将被挂起或者阻塞的进程加入等待队列,来等待唤醒或者恢复。
3.2 上下文切换
Linux 进程的上下文切换就是从一个可执行进程切换到另一个可执行进程,在 kernel/sched.c 中的 context_switch 函数实现,当内核调用 schedule 运行进程的时候,就会调用该函数。
主要就做了两步操作:
- 通过 switch_mm 函数切换虚拟内存;
- 通过 switch_to 切换处理器状态;
static inline task_t * context_switch(runqueue_t *rq, task_t *prev, task_t *next)
{
struct mm_struct *mm = next->mm;
struct mm_struct *oldmm = prev->active_mm;
if (unlikely(!mm)) {
next->active_mm = oldmm;
atomic_inc(&oldmm->mm_count);
enter_lazy_tlb(oldmm, next);
} else
// 切换新的 mm_struct 也就是进程地址
switch_mm(oldmm, mm, next);
if (unlikely(!prev->mm)) {
prev->active_mm = NULL;
WARN_ON(rq->prev_mm);
rq->prev_mm = oldmm;
}
// 切换处理器状态,包括栈,寄存器等信息的保存和恢复
switch_to(prev, next, prev);
return prev;
}
另外,内核需要知道什么时候应该调用 schedule,所以内核提供了一个 need_resched 的标志来表明是否需要重新执行一次调度,当高优先级进程进入可执行状态,或者进程中断返回的时候,都会设置该标志。
四、总结
进程是一个正在运行程序的实例,是操作系统分配资源的基本单位,进程包括正文段,数据段,堆栈,打开的文件,挂起的信号,处理器状态等内容;在 linux 中,进程包含一个唯一的 task_struct 结构体来修饰,一般通过 fork() 和 exit() 来创建和销毁。
线程是运行在进程中的一段逻辑流,是操作系统调度的基本单位,共享进程的资源。在 Linux 中,并没有线程的单独概念,它也包含一个 task_struct 的结构,被看作是一个与多个进程共享资源的特殊进程,它的创建也和 fork 类似,使用 clone() 的系统调用创建,不过会多加入一些参数来标识需要共享哪些资源。
内核线程没有独立的地址空间,只在内核空间运行。