深入理解linux内核系列--第三章:进程

深入理解linux内核—进程

进程。 插入一句题外话,我对比了《深入理解linux内核》和《深入Linux内核架构》,我觉得后者更适合不那么深入了解的初学者,共勉之
PS:我写BLOG更多为了记录读书笔记内容,我觉得这有写的更好的专栏,推荐给大家。
https://blog.csdn.net/gatieme/category_6225543.html

进程、轻量级进程和线程

进程:一个运行过程的实例,是分配系统资源(CPU时间、内存等)的实体。
创建时与父进程几乎完全相同,并且即将执行完全相同的程序正文,但是有独立的数据拷贝比如栈和堆。意味者如果此时修改的是栈上的数据,则对父进程不可见。
线程:共享进程的内存地址空间(How?),统一打开文件集但是独立调度

进程描述符(非常重要)

task_struct,相当多的内容。目前包含主要关注的信息,会在后续笔记中,不断的丰富进程描述符包含的字段。


struct task_struct {
	/* 进程的基础信息*/
	struct thread_info		thread_info;  
	
	/*进程的memory mapping信息,也就是内存信息*/
	struct mm_struct		*mm;
	struct mm_struct		*active_mm;
	
	/* Filesystem information: 目录信息*/
	struct fs_struct		*fs;

	/* Signal handlers: 信号信息*/
	struct signal_struct		*signal;
	struct sighand_struct __rcu		*sighand;	
	
	...

}

进程状态

state参数,进程的状态包括 running, interruptible,  uninterruptible, stopped , traced ,zombie等,此处不做过多描述,可参考其他的文档。

简单的赋值状态 p->state=TASK_RUNNING

标识进程

每个线程都有自己的task_struct结构,因为要参与调度和拥有独立的上下文。
使用PID来标识进程,最大ID保存在/proc/sys/kernel/pid_max中,通常为32k,64位下为4M,pidmap_array位图表示已分配PID,32位需要一页 4KB=32Kb, 64位貌似会动态增加1

tpid 用于保存同组的pid,类似与组id,同一个进程下的线程都返回同样的getpid()的结果。

进程描述符处理

进程描述符和线程描述符(thread_info)和内核栈的浅略关系图
深入理解linux内核系列--第三章:进程_第1张图片
需要注意的是,但从用户态切换到内核态时,此时的内核栈为空,thread_info大概占用52B,则剩余的8K-52B都可以用于内核栈2

此图的代码实现为

union thread_union {
	struct thread_info thread_info;
	unsigned long stack[THREAD_SIZE/sizeof(long)];
};

此处内核使用接口alloc/free_thread_info宏用于申请和回收此类的地址空间

current宏的实现
current宏用于标识当前的进程描述符, 实际等价于current_thread_info()->task,等价于使用当前内核栈的8K起始地址(此时为thread_info结构体)的第一个元素(task_struct)的地址。

所以task_struct应该在内存中,但是thread_info应该在内核空间中?是这样吗?

双向链表

此章节涉及到了内核的多种数据结构的实现,如果感兴趣的人可以单独作为一个章节来学习,此处不做详细描述。

注意:双向链表仅仅表示链表的链接关系(指针指向),并不会涉及到任何的实际结构体(例如进程列表,映射空间表,驱动的driver和deivce列表等)的内容。

	
struct list_head {
	struct list_head *next, *prev;
};
	
/*初始化时将prev 和 next都指向自己*/
#define LIST_HEAD(name) \
	struct list_head name = LIST_HEAD_INIT(name)

hlist用于存放散列表,不太看中查找时间而是可以节省空间。有兴趣自己了解

进程链表

系统有进程链表,用于链接所有的进程,init_task描述符是指进程0的进程描述符
每个进程都有进程链表(tasks字段,类型为list_head),指向前后的task_struct元素中的tasks字段(也就是指向其他的进程3)。所以init_task的task字段的prev指向的就是最后插入的进程描述符的tasks字段

遍历进程列表
#define for_each_process(p) \
	for (p = &init_task ; (p = next_task(p)) != &init_task ; )

/* 可以粗略的理解为当前的process的tasks的next指向的是另一个进程描述符task_struct的tasks的结构
   从而可以返回对应的task_struct的指针,当然会使用到老朋友container_of(READ_ONCE(ptr), type, member)
*/
#define next_task(p) \
	list_entry_rcu((p)->tasks.next, struct task_struct, tasks)

Running的进程链表

为什么要维护这个表?因为要选取下一个执行的进程。早期通过遍历来获取,2.6以后确保固定时间内可以获取(与进程数量无关)

根据优先级可选值0-139分为140个队列,而且维护了位图和当前总的可执行进程数量。所以仅需要找到优先级最小的可执行队列的头部即可找到下一个可执行的进程。

进程间亲属关系

父子关系: 父亲(1:1),child(prev和next)
兄弟关系:前后关系,且领头的子进程的前兄弟指向的是父进程(大哥没有大哥,只有爸爸。同理,幺儿子没有弟弟,只有爸爸)

同时,包含了进程组头的描述符指针(group_leader),进程组头pid,线程组头pid,登录会话pid,以及trace的pid等等。
为了快速根据PID查找,维护了多组hash表用于快速获取task_struct

如何组织进程

停止或者僵死的进程不会特意组织,但是对于INTERRUPTIBLE和UNINTERRUPTIBLE的进程有多种不同的分类方式。

等待队列

等待队列表示一组睡眠进程,等到条件满足时,内核唤醒他们
等待可以有很多情况,包括等待时间、资源、磁盘等等。
等待队列的头需要一个锁,用于确保访问的互斥。等待队列的结构为wait_queue

struct wait_queue_head {
	spinlock_t		lock;
	struct list_head	head;
};
/*通用的等待队列的结构如下*/
struct wait_queue_entry {
	unsigned int		flags;
	void			*private;	//对于等待进程队列,此处存放的是task_struct 
	wait_queue_func_t	func;
	struct list_head	entry;
};

唤醒:避免互斥资源的全部唤醒,可以设置为互斥唤醒模式,flags的字段=1。但是如果flags=0则全部唤醒。
唤醒方式func字段,默认为default_wake_function,通常是try_to_wake_up的封装
需要注意的是,非互斥添加在队头,互斥则是添加在队尾。(很容易理解,非互斥全部唤醒,但是互斥的仅唤醒有限进程)

睡眠函数

睡眠通过将process加入到wait queue来实现,并进行调度(释放CPU),如下分别在可中断、睡眠时间和睡眠准备等不同的情况下使用不同的API接口。
sleep_on
interruptible_sleep_on
sleep_on_timeout 需要和后面的动态定时器的应用章节相关
prepare_to_wait和prepare_to_wait_exclusive和finish_wait
wait_event和wait_event_interruptible

内核唤醒函数

wake_up,wake_up_nr,wake_up_all等等,interruptible的等待队列一定被考虑,uninterruptible的进程不在仅仅唤醒可中断的接口中被考虑。具体操作就是尝试根据task_list获取当前的wait_queue并且执行func函数检查flag的状态

资源限制

current->signal->rlim resource limit,包括了最大地址空间,内存转存,CPU最长,最大堆,文件大小,最大锁,最大消息队列。。。等等

重头戏:进程切换

进程切换仅仅发生在内核态

硬件上下文

硬件上下文一部分在TSS(Task State Segment任务状态)段,剩余部分在内核态堆栈(包括用户态所有的寄存器内容)中。简单来说,使用next来取代prev
进程切换一直在执行,要在尽可能短的时间内完成。

TSS

每个CPU一个TSS,直接上代码。

#ifdef CONFIG_X86_32
struct tss_struct {
	/*
	 * The fixed hardware portion.  This must not cross a page boundary
	 * at risk of violating the SDM's advice and potentially triggering
	 * errata.
	 */
	struct x86_hw_tss	x86_tss;

	struct x86_io_bitmap	io_bitmap;
} __aligned(PAGE_SIZE);


struct x86_hw_tss {
	unsigned short		back_link, __blh;
	unsigned long		sp0;
	unsigned short		ss0, __ss0h;
	unsigned long		sp1;

	/*
	 * We don't use ring 1, so ss1 is a convenient scratch space in
	 * the same cacheline as sp0.  We use ss1 to cache the value in
	 * MSR_IA32_SYSENTER_CS.  When we context switch
	 * MSR_IA32_SYSENTER_CS, we first check if the new value being
	 * written matches ss1, and, if it's not, then we wrmsr the new
	 * value and update ss1.
	 *
	 * The only reason we context switch MSR_IA32_SYSENTER_CS is
	 * that we set it to zero in vm86 tasks to avoid corrupting the
	 * stack if we were to go through the sysenter path from vm86
	 * mode.
	 */
	unsigned short		ss1;	/* MSR_IA32_SYSENTER_CS */

	unsigned short		__ss1h;
	unsigned long		sp2;
	unsigned short		ss2, __ss2h;
	unsigned long		__cr3;
	unsigned long		ip;
	unsigned long		flags;
	unsigned long		ax;
	unsigned long		cx;
	unsigned long		dx;
	unsigned long		bx;
	unsigned long		sp;
	unsigned long		bp;
	unsigned long		si;
	unsigned long		di;
	unsigned short		es, __esh;
	unsigned short		cs, __csh;
	unsigned short		ss, __ssh;
	unsigned short		ds, __dsh;
	unsigned short		fs, __fsh;
	unsigned short		gs, __gsh;
	unsigned short		ldt, __ldth;
	unsigned short		trace;
	unsigned short		io_bitmap_base;

} __attribute__((packed));

#else
struct x86_hw_tss {
	u32			reserved1;
	u64			sp0;
	u64			sp1;

	/*
	 * Since Linux does not use ring 2, the 'sp2' slot is unused by
	 * hardware.  entry_SYSCALL_64 uses it as scratch space to stash
	 * the user RSP value.
	 */
	u64			sp2;

	u64			reserved2;
	u64			ist[7];
	u32			reserved3;
	u32			reserved4;
	u16			reserved5;
	u16			io_bitmap_base;

} __attribute__((packed));
#endif

接下来的描述包含了很多段描述和系统寄存器的内容,总的原理就是要快速的访问当前进程和系统的段描述符内容。

TSS段有一个段描述符TSSD,且TSSD中的S=0表示为系统段,type=9/11表示是TSS段,TSSD存放在GDT中,GDT通过每CPU的gdtr里面,tr寄存器包含了gdt中的TSSD的段选择符(可以理解为下标),同时tr还包含了TSSD的base和limit字段(用于跳过GDT直接寻址TSS)

用户态切换到内核态,则获取TSS中内核态堆栈的地址,IO访问需要TSS段中的io许可权位图(同时检查eflags上的特权等级)

thread字段

结构体为thread_struct,注意区分thread_info 存放了大部分CPU寄存器,但是有些通用寄存器存放在内核堆栈中

进程切换

1:切换全局页目录安装新的地址空间
2:切换内核态堆栈和硬件上下文

switch_to宏

switch_to(prev,next,last),为什么需要last变量,简单来说,需要一个A->B->C->A的过程,而且在进程切换时需要用到上一个进程的部分信息,所以需要保存last的变量,否则在A执行的时候没有任何的C的相关信息。

汇编代码原文写的比较乱序,不妨将prev=next,看一个自己切换到自己的过程是如何完成的。
保存:将prev存入eax寄存器,通过压栈push当前ebp和fl寄存器到内核栈,保存当前esp地址到prev->thread.esp,并设置下一个指令(恢复时执行)prev->thread.eip为label 1,
执行切换 switch_to
恢复: 装入保存在thread中的esp和eip,此时eip指向lable1,执行label1(装载push的ebp和f1寄存器)。恢复切换前执行流程。

__switch_to()函数

主要还是一些寄存器和位图等的保存和恢复操作。需要说明的是linux对于可能不会使用的寄存器,会推迟进行保存和恢复。例如FPU、MMX、XMM寄存器等
保存时通过设置的TS_USEDFPU等标志位决定是否需要保存相关寄存器信息。同时通过TS(task-switching)位,从而仅在后续的进程需要使用到相关寄存器时,触发Device not available异常,才使用之前保存的寄存器内容进行加载。
注意内核态使用需要显示说明、

创建进程

创建进程,实际上核心是复制。对于进程和线程的差别,实际是对于复制时需要保留不变的内容的flag的选择。
其中核心FLAG,VM,FS,FILES,SIGHAND等决定了文件、内存、信号处理等资源的共享性。

我们尽可能高层次的考虑进程创建时发生的事情。

do_fork()
	copy_process()
		/*参数检查:尤其是各种flag的一致性检查*/
		dup_task_struct() /*创建新的task_struct结构*/
		/*检查和更新进程数量相关参数*/
		/*例如,用户最大进程数,全局最大进程数,用户计数(被引用的次数)
		/*初始化父进程使用过程修改的部分参数
			比如初始化状态,lock_depth(大内核锁层数),did_exec层数(execve数量)
			比如list_head,挂起信号、定时器、时间统计等赋初值
		*/
		/*根据Flag,修改PID并在合适的结构组中添加PID内容*/
		copy_semundo(), copy_files(), copy_fs(), copy_sighand(),copy_signal(), copy_mm(), copy_namespace()
		
	wake_up_new_task()
		/*调整调度参数*/
		/*如果共享页表 CLONE_VM=0,则子进程插入到父进程前,避免大量的页面复制(写时复制模式),否则放置在父进程后面*/
		/*如果设置了VFORK,则挂起父进程到等待队列,知道子进程释放地址空间*/
		/*返回pid*/
		

内核线程

将内核函数,委托给了独立进程。经常称之为内核守护线程。通常包括几种
1:一直等待,知道内核请求执行某操作。
2:周期运行,检查资源等

kthread_create()用于创建内核线程,仅运行在内核空间,所以切换到内核线程时,无需修改task_struct的mm指针。可以有效的解决切换内核线程时出现的TLB表失效问题。
注意,kthread_create()需要使用wake_up_process来启动
但是kthread_run则会直接创建并且执行
kernel_thread可以用于启动一个内核线程

启动进程

execve ,do_execve

do_execve()
	/*打开可执行文件,获取fd*/

	bprm_init()
		mm_alloc()	/*execve所以创建新的mm结构*/
		init_new_context()	/* 初始化特定于体系结构的函数 */
		__bprm_mm_init()	/*建立初始化的栈结构*/
	prepare_binprm()
	search_binary_handle()

退出进程

exit结束调用,入口为sys_exit。具体就是将计数减一,并且回收相关的资源。
包括但不限于: del_timer_sync(), exit_mm(), exit_sem() , __exit_files() __exit_fs(), exit_namespace() , exit_thread()

进程删除:release_task(), ()释放内存、回收描述符、删除信号处理函数、删除PIDs


  1. 此处未详细描述,只是说明了增加以后会一直保存位图。 ↩︎

  2. 内核栈通常用于进行中断处理等内核操作。 ↩︎

  3. 书中此处写的是指向前后的task_struct字段,个人觉得不太对 ↩︎

你可能感兴趣的:(深入理解Linux内核,linux,运维,服务器)