Linux 进程(查漏补缺版)

一、概述

进程就是处于执行期的程序,通常包括内容:正文段、数据段、打开的文件、挂起的信号、内核内部数据、处理器状态,内存映射的地址空间、执行线程等。

线程是进程中活动的对象,每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。对于 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_STOPPEDTASK_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 主要用于以下两种情况:

  1. 类似于 Reactor 模型,父进程复制一个子进程执行不同的代码段,例如网络服务进程,父进程接受连接之后,交给子进程继续执行具体业务,而父进程继续监听连接。
  2. 父进程要执行一个不同的程序,例如 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() 的系统调用创建,不过会多加入一些参数来标识需要共享哪些资源。

内核线程没有独立的地址空间,只在内核空间运行。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值