Linux内核简单分析(1)——进程管理

何为进程?

许多的内核书籍都会把进程放在比较靠前的章节介绍,因为进程是内核中一个比较重要的概念,很多东西都是围绕进程来展开的,毕竟系统的任务就是运行进程嘛,这里我也把进程放到最前面来介绍。

进程的概念学过操作系统的都懂,比较字面的解释也就不说了,进程一般是一个正在运行的程序,它还包括这个进程所拥有的资源,比如打开的文件,CPU寄存器里面的数据,进程所拥有的地址空间(例如申请的变量,堆和栈等)。

从概念上来讲,每个进程里面可以只有一个线程,也可以有多个线程,线程才是真正占用CPU的最小单位,就是说一个CPU同一时刻只能运行一个线程,而不能说运行一个进程。但是在Linux下线程的实现比较特殊,不同于其他系统,虽然线程的单位比进程小,但是内核用来表示他们的数据结构却是相同的(这部分内容后面再讲),线程在Linux中又被称之为轻量级进程。

我们的程序在运行时都好像能够一直使用CPU,好像是每个进程都拥有一个CPU一样,这其实是进程间不断切换,轮流占用CPU的结果,称之为进程调度。而我们在编写一个程序时可以向系统要求分配内存(如malloc),使用起来的感觉像是拥有整个系统的内存资源一样,这其实就是虚拟内存机制的功劳,它能将物理上的内存条容量大小扩充成逻辑上的内存大小(跟系统的位数有关)。

如何表示进程?

Linux内核使用一个名为task_struct的数据结构来表示进程,称之为进程描述符(该结构定
义在include/sched.h中,不过这跟我是个靓仔好像没有什么关系,所以后面关于这些文件位置的都不再提了) 。
这是一个很大的结构,包含了进程的几乎所有信息,它里面的数据能够完整的描述一个进程, 当然,它里面也有很多指针,指向其他的数据结构,将进程和内核其他模块联系起来。因为里面字段太多了,而且很多内容都还没介绍,所以一开始就要完全理解显然不现实,所以下面先给出task_struct结构体的简化版本以及简图以便理解,待后面涉及到该结构时再回过头看一下特定部分即可。

struct task_struct {
	volatile long state; /* 进程的运行状态 */
	void *stack; /* 进程内核栈位置 */
	atomic_t usage; /* 该结构正在被多少个进程使用,相当于一个引用计数 */
	unsigned long flags; /* 表示进程的状态信息,用于内核识别当前进程的状态 */
	unsigned long ptrace;
	int lock_depth; /* 大内核锁深度 */
	int prio, static_prio, normal_prio; /* 优先级 */
	struct list_head run_list; /* 运行队列 */
	const struct sched_class *sched_class;
	struct sched_entity se;
	unsigned short ioprio;
	unsigned long policy;
	cpumask_t cpus_allowed;
	unsigned int time_slice;

	struct list_head tasks; /* 任务队列,
	/*
	* ptrace_list/ptrace_children链表是ptrace能够看到的当前进程的子进程列表。
	*/
	struct list_head ptrace_children;
	struct list_head ptrace_list;
	struct mm_struct *mm, *active_mm;
	/* 进程状态 */
	struct linux_binfmt *binfmt;
	long exit_state;
	int exit_code, exit_signal;
	int pdeath_signal; /* 在父进程终止时发送的信号 */
	unsigned int personality;
	unsigned did_exec:1;
	pid_t pid;
	pid_t tgid;
	/*
	* 分别是指向(原)父进程、最年轻的子进程、年幼的兄弟进程、年长的兄弟进程的指针。
	*(p->father可以替换为p->parent->pid)
	*/
	struct task_struct *real_parent; /* 真正的父进程(在被调试的情况下) */
	struct task_struct *parent; /* 父进程 */
	/*
	* children/sibling链表外加当前调试的进程,构成了当前进程的所有子进程
	*/
	struct list_head children; /* 子进程链表 */
	struct list_head sibling; /* 连接到父进程的子进程链表 */
	struct task_struct *group_leader; /* 线程组组长 */
	/* PID与PID散列表的联系。 */
	struct pid_link pids[PIDTYPE_MAX];
	struct list_head thread_group;
	struct completion *vfork_done; /* 用于vfork() */
	int __user *set_child_tid; /* CLONE_CHILD_SETTID */
	int __user *clear_child_tid; /* CLONE_CHILD_CLEARTID */
	unsigned long rt_priority;
	cputime_t utime, stime, utimescaled, stimescaled;
	unsigned long nvcsw, nivcsw; /* 上下文切换计数 */
	struct timespec start_time; /* 单调时间 */
	struct timespec real_start_time; /* 启动以来的时间 */

	/* 进程身份凭据 */
	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; 
	char comm[TASK_COMM_LEN]; /* 除去路径后的可执行文件名称
	 -用[gs]et_task_comm访问(其中用task_lock()锁定它)
	 -通常由flush_old_exec初始化 */
	/* 文件系统信息 */
	int link_count, total_link_count;
	/* ipc相关 */
	struct sysv_sem sysvsem;
	/* 当前进程特定于CPU的状态信息 */
	struct thread_struct thread;
	/* 文件系统信息 */
	struct fs_struct *fs;
	/* 打开文件信息 */
	struct files_struct *files;
	/* 命名空间 */
	struct nsproxy *nsproxy;
	/* 信号处理程序 */
	struct signal_struct *signal;
	struct sighand_struct *sighand;
	sigset_t blocked, real_blocked;
	sigset_t saved_sigmask; /* 用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;

	...
}; 

在这里插入图片描述

进程有什么状态?

task_struct中的state字段描述了进程的状态,Linux中进程的可能状态有七种,一个进程从诞生到死亡的过程中会在这七种状态中来回切换。学过操作系统的朋友们都知道进程一般都是在就绪态、运行态、阻塞态中来回切换,直到进程退出。
在这里插入图片描述
在linux中,把就绪态和运行态合称可运行状态(TASK_RUNNING),而把阻塞态分成两个等待状态,可中断的等待状态(TASK_INTERRUPTIBLE)和不可中断的等待状态(TASK_UNINTERRUPTIBLE),两者都是进程处于睡眠时候的状态,不同的是后者不接受信号的唤醒,即只会在阻塞的条件满足时才会被唤醒。
在这里插入图片描述
另外还有两个不常见的状态:暂停状态(TASK_STOPPED)和跟踪状态(TASK_TRACED)。暂停状态出现于进程接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号时,进程暂停运行,同时也不能被投入运行,这个状态主要出现在调试进程的时候。跟踪状态一般出现在一个进程监控另一个进程时,如一个debugger程序执行ptrace()系统调用监控另一个程序。

进程退出之后,因为系统并不是马上收回进程资源,所以此时仍需要表示状态,有两个状态:僵死状态(EXIT_ZOMBIE)和退出状态(EXIT_DEAD)。相信能看到这篇文章的朋友应该都了解僵死状态的定义吧,即父进程创建子进程后,子进程退出了,但父进程没有调用wait for系统调用导致子进程资源不能被系统回收(其实只是进程pid以及其他一点资源,大部分已经被系统回收了,而为什么要让父进程替子进程收尸就是另外一个故事了),这时子进程就处于僵死状态了,而当父进程使用wait for获取子进程状态后,子进程的pid就会被系统收回了,因为父进程可能会有多个线程,为了防止多个线程都同时执行wait for,所以在子进程消失之前插入一个中间状态退出状态,以避免产生竞争。

进程看到的内存空间长什么样?

通常把进程看到的内存称之为进程地址空间,进程分为内核态和用户态,内存也就自然分为内核空间和用户空间,只有进入内核态后才能访问内核空间。如下图,一般内核空间和用户空间大小比例为1:3,比如32位机下1G:3G,当然这并不绝对。注意并不是进程真的拥有这么大的内存空间,这是虚拟内存机制通过分页造成的假象。
在这里插入图片描述

如何访问进程的task_struct结构?

内核中为了减轻内存碎片,加快分配内存的速度,提供了一个slab分配器的机制,我们暂且把它当作一个对象的高速缓存,即slab分配器里面已经有分配好内存的数据结构了,我们需要的时候直接取用即可。所以当新创建一个进程,要生成一个task_struct结构时,直接从slab里面取出一个task_struct来使用,那么我们就知道了task_struct结构放在由slab管理的内存中,而slab的内存又处于内核空间中。

现在就有一个新问题了,进程如何访问这个task_struct结构呢?也就是如何得到这个结构体的地址呢?

首先一个进程不仅在用户空间有栈,在内核空间同样有一个内核栈,这个栈的大小一般是两个连续的页,32位下页大小为4KB,两页为8KB,这里的连续指的是物理内存上的连续。进程从用户态切换到内核态需要做的一项工作就是把CPU上的栈指针寄存器esp里面的值从用户态的栈地址变为内核栈的地址,这样直接通过esp就能访问到栈了。这里注意一点,每次从用户态切换到内核态,内核栈里面都是空的,所以直接把内核栈起始位置指针给esp即可,之所以为空是因为内核没必要保留运行状态。

好了,现在来看一下如何通过内核栈访问到task_struct。首先内核栈的数据结构是一个联合体,如下所示:

Union thread_union {
	Struct thread_info thread_info;
	Unsigned long stack[THREAD_SIZE/sizeof(long)];
 };

对应到实际的内存中,布局如下所示:(注意地址是从下往上增长的,所以上面代码顺序并不会错)
在这里插入图片描述

这样当通过esp访问到栈地址后,因为栈的大小是固定的,所以又可以找到thread_info的地址,继而通过thread_info里面的task指针就可以访问到task_struct结构了。

为什么要有thread_info呢?一个原因是为了不使用额外的寄存器也能访问到task_struct,像这里直接借用了esp,而又不影响esp本来的作用。另一个原因是linux支持多种体系结构,于是在thread_info中存放特定于体系结构的信息,而task_struct存放通用的信息,将体系结构有关和无关的部分分离。thread_info的定义如下:

struct thread_info {
	struct pcb_struct	pcb;		/* palcode state */
 
	struct task_struct	*task;		/* main task structure */  /*这里很重要,task指针指向的是所创建的进程的struct task_struct
	unsigned int		flags;		/* low level flags */
	unsigned int		ieee_state;	/* see fpu.h */
 
	struct exec_domain	*exec_domain;	/* execution domain */  /*表了当前进程是属于哪一种规范的可执行程序,
                                                                        //不同的系统产生的可执行文件的差异存放在变量exec_domain中
	mm_segment_t		addr_limit;	/* thread address space */
	unsigned		cpu;		/* current CPU */
	int			preempt_count; /* 0 => preemptable, <0 => BUG */
 
	int bpt_nsaved;
	unsigned long bpt_addr[2];		/* breakpoint handling  */
	unsigned int bpt_insn[2];
 
	struct restart_block	restart_block;
};

如何创建进程?

Linux创建进程的方式比较特殊,不同于其他操作系统的直接产生进程,它首先需要在一个进程里面,然后使用fork()函数拷贝出当前进程的一个副本,然后在这个副本进程里面执行exec()函数,清理掉原有的东西,载入新的内容,从而实现创建新的进程。我们平时使用shell执行命令的原理其实就是这样,在shell这个进程里面使用fork复制一个进程,再在复制的进程里面载入对应命令的那个程序文件。
在这里插入图片描述

fork()

首先说一下fork()如何使用,在使用fork()后,该函数会返回两次,一次在父进程中,返回值是创建的子进程的pid,一次在子进程中,返回的是0,程序就是根据这个返回值来判断自己是父进程还是子进程。

具体实现方面,fork()函数会调用clone(),而clone()又调用do_fork(),do_fork()又会利用copy_process()来创建进程描述符和其他子进程需要的内核数据结构。fork()函数大概执行的操作如下:
1、为子进程分配一个新的可用的pid。
2、分配一个进程描述符task_struct结构,将其地址保存为tsk局部变量。
3、分配一块空闲内存区,用来存放内核栈和thread_info结构(参见上一节),将其地址保存为ti。
4、将当前进程的task_struct内容复制到tsk指向的task_struct中,并将tsk->thread_info置为ti。
5、同样,将当前进程thread_info复制到ti,并将ti->task置为tsk。这样就完成了task_struct和thread_info的初步复制。
6、把新进程的pid保存到tsk->pid中。
7、调用copy_mm(),copy_files()等创建新的数据结构,并更新task_struct里相应的指针指向,然后将原进程相应数据结构的内容复制过来。这样就相当于深拷贝了。

上面只列出了我关心的关于数据结构的复制操作,还有很多安全检查及其他操作没有给出。不过这已经足够说明了fork()其实就是把父进程的所有东西尤其是task_struct复制一遍,然后修改一下标识使得两者区分开,比如进程pid。

写时复制

但是如果只是完完整整地把父进程的所有东西拷贝一遍,包括底层的数据的话,这开销未免也太大了,尤其是在像shell中执行命令的情况,使用fork只是为了创建新进程,然后执行新程序,对于旧进程数据的拷贝都将变得没有意义。所以Linux使用一种叫做写时复制(copy-on-write)的技术。

如下图所示,进程的内存地址空间由vm_area_struct结构组织起来,所以当使用fork时,由于写时拷贝的存在,那么子进程会复制下图的所有内容,以及父进程的页表,这样由于虚拟内存机制的存在,虽然两个进程都有自己的进程地址空间(下图的进程虚拟内存),但它们其实是指向物理内存中的同一块数据。
在这里插入图片描述

使用fork后两个进程的内存情况大概如下图,两个进程都有自己的虚拟内存,但由于页表是相同的,所以指向同一块物理内存。当有一个进程要修改数据时,那么这时才会发生数据的拷贝,并通过修改页表使得虚拟内存映射到新拷贝的物理内存上。copy-on-write就是通过这种方式推迟甚至免除了数据的拷贝,提高了创建进程的速度。
在这里插入图片描述

vfork()

vfork()出现在还没有copy-on-write机制的时候,它的语义是不复制父进程的页表,共享父进程的地址空间,并且确保子进程在父进程之前运行,直到子进程执行exec()后父进程才能继续运行,这个优化就是为了执行exec()而搞的。但是现在的fork()已经能够确保子进程先于父进程运行,并且有了写时复制技术,所以vfork()就没什么意义了。

vfork()在实现方面跟fork的区别就是只复制task_struct,而不复制下面的子结构,参考上面的图片,新的进程与旧进程使用同一个mm_struct,这样就实现了共享地址空间,而当执行exec后,mm_struct就被更新了。

如何创建线程?

首先呢,Linux中的线程实现十分有趣,他和进程使用同样的数据结构task_struct描述,因为同一个进程里面的多个线程是共享地址空间的,所以很明显,像上面的vfork实现一样,他们的task_struct里面的mm字段指向同一个mm_struct。

到这里就可以回答一个经典的面试问题了:进程和线程的区别?普通人的回答是从操作系统书上的概念来回答,像什么 进程是分配资源的基本单位,线程是调度的基本单位,自己都搞不清是怎么回事。我们可以直接从linux底层实现角度来回答:在Linux中,进程和线程的本质是一样的,都是使用task_struct结构来表示的,不同的是不同进程之间都有自己的进程地址空间,互不干扰,而同一个进程里面的线程之间通过task_struct结构里面mm字段指向同一个结构实现共享地址空间;另外线程也有只属于自己的东西,比如线程栈,线程局部存储,寄存器的值等。因为进程里的线程共享地址空间,所以一旦其中一个线程挂了,那么可能会破坏地址空间里面的内容,所以整个进程就会挂掉,而进程之间不会互相影响,所以进程适用于可靠性要求较高的程序,以及分布式系统。但也是因为共享地址空间,线程之间的通信十分方便且快速,而进程间需要复杂的进程间通信机制。

clone()

在Linux中,线程的创建是由clone()系统调用创建的,而我们之前的进程创建fork()也是用clone()实现的,只是调用clone()的参数不同而已。这也说明了在linux中不区分线程和进程的概念。

在使用clone时,通过传入特定的标志让新建立的进程与原进程共享地址空间,共享打开的文件,共享文件系统信息等,这样就使得新建立的进程在某种意义上就是我们想要的线程了。具体clone的参数和各种标志就不展开讲了,有兴趣的读者自行去查阅。(反正我是没什么兴趣

内核进程和普通进程有什么区别?

内核进程也可以称作内核线程,因为Linux并不区分进程和线程。内核进程与普通的区别在于它始终运行在内核空间,并且他没有独立的地址空间,所以他的task_struct里面的mm字段为NULL。那么内核进程有什么用呢?其实就类似于后台进程,比如会有一个内核进程固定时间把内存上的脏数据写到磁盘上。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值