概述
最近开始学习Linux内核,主要参考书籍为《Linux内核设计与实现》,所以本系列文章大章节和小章节会遵从原书结构,再辅以其他书籍或网上资料对其未理解部分进行补充。
本系列定位为初级文档,不会详细阐述实现原理,只讲解概念和逻辑。
本章目录
进程概念
进程是Linux内核最基本的抽象之一,它是处于执行期的程序(OS中的概念),或者说“进程=程序+执行”。但是进程并不仅局限于一段可执行代码(代码段),它还包括进程需要的其他资源,例如打开的文件、挂起的信号量、内存管理、处理器状态、一个或者多个执行线程和数据段等。
线程被称为轻量级进程(lightweight process),它是操作系统调度的最小单元,通常一个进程可以拥有多个线程。
线程和进程的区别在于进程拥有独立的资源空间,而线程则共享进程的资源空间。换句话说,进程与进程的地址空间是独立的,线程共享进程的地址空间。
Linux内核并没有对线程有特别的调度算法或定义特别的数据结构来标识线程,线程和进程都使用相同的进程PCB数据结构。对Linux 而言,线程只不过是一种特殊的进程罢了。
在Linux系统中,通常使用fork()
创建一个全新的线程。调用fork()
的进程称为父进程,新产
生的进程称为子进程。在该调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开
始执行。
fork()
系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。
在这儿引入一个sample:
/* sample1:通过fork创建线程 */
#include<sys/types.h>
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
int main()
{
printf("this is fork sample %dn",getpid());
pid_t pid;/*pid 进程id号*/
pid=fork();/*创建一个新进程*/
printf("pid:%dn",pid);
if(pid==0) /*返回0为子进程*/
{
printf("Return pid is %dn",pid);
printf("This is son process! pid is:%dn",getpid());
}
else if(pid>0)/*返回大于0为父进程*/
{
printf("Return pid is %dn",pid);
printf("This is parent process! pid is:%dn",getpid());
waitpid(pid,NULL,0);/*等待子进程退出*/
}
else
{
perror("fork() error!");
exit(0);
}
}
输出
this is fork sample 183
pid:184
pid:0
Return pid is 0
Return pid is 184
This is parent process! pid is:183
This is son process! pid is:184
可以看到从fork()
父进程返回了一次,子进程返回了一次。
父进程返回子进程ID,子进程返回0。
子进程也是从fork()
处继续执行的,而不是从头执行!
在引入两个思考题:
#include <unistd.h>
#include <stdio.h>
int main(void)
{
int i=0;
printf("i son/pa ppid pid fpidn");
//ppid指当前进程的父进程pid
//pid指当前进程的pid,
//fpid指fork返回给当前进程的值
for(i=0;i<2;i++){
pid_t fpid=fork();
printf("pid:%dn",fpid);
if(fpid==0)
printf("%d child %4d %4d %4dn",i,getppid(),getpid(),fpid);
else
printf("%d parent %4d %4d %4dn",i,getppid(),getpid(),fpid);
}
return 0;
}
总共打印多少次pid:xx
?
总共打印了6次pid:xx
。
推导过程引用网上资料:
对于有for循环的情况,总进程为2^0+2^1+….+2^n,子进程为(2^n)-1。
#include <stdio.h>
#include <sys/types.h>
int main()
{
fork();
fork();
printf("hellon");
return 0;
}
总共创建了多少个hello
?
答案是4个。
推导流程如下:
第一次fork
产生2进程,第二次fork
产生了3,4进程。
可以简单总结一个规律,对于此类情况,总进程数为2^n
,该例子中,n为2,所以总进程为4。
思考题结束,进入正题。
创建新的进程都是为了立即执行新的、不同的程序,而接着调用exec()
这组函数
就可以创建新的地址空间,井把新的程序载入其中。
#include<stdio.h>
#include<unistd.h>
int main()
{
int i;
printf("I am EXEC.c called by execv() ");
printf("n");
return 0;
}
将上述文件编译为可执行文件exec
#include <stdio.h>
#include <sys/types.h>
int main()
{
int pid,status;
pid = fork();
if(pid == 0){
char *args[]={"./exec",NULL};
execv(args[0],args);
printf("exec failed!n");
exit(1);
}
else{
printf("parent waitingn");
wait(&status);
printf("the child exited with status %dn",status);
}
exit(0);
}
输出
parent waiting
I am EXEC.c called by execv()
the child exited with status 0
子进程通过exit()
系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。(线程退出章节会详讲)
进程描述符及任务结构
内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中。
链表中的每一项都是类型为task_struct、称为进程描述符(process descriptor ),用于存放具体进程的所有信息。
进程描述符中一个重要成员为 thread_info
,记录部分进程信息的结构体,其中包括了进程上下文信息。
task_struct
数据结构中的stack
成员指向thread_union
结构(Linux内核通过thread_union
联合体来表示进程的内核栈)
struct task_struct
{
// ...
void *stack; // 指向内核栈的指针
// ...
};
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};
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;
};
这样的好处是可以通过stack
,thread_info
或task_struct
任意一个数据结构的地址,就可以很快得到另外两个数据的地址。
实际上,current
宏也是这样去设计的。
内核通过一个唯一的进程标识值(PID)来标识每个进程。
通过current
宏查找到当前正在运行进程的进程描述符的速度就显得尤为重要。
参考在ARM下的实现
#define get_current() (current_thread_info()->task)
#define current get_current()
-----
register unsigned long current_stack_pointer asm ("sp");
static inline struct thread_info *current_thread_info(void)
{
return (struct thread_info *)
(current_stack_pointer & ~(THREAD_SIZE - 1));
}
根据SP
获取到thread_info
的地址,然后根据成员获取到current
。
看一个实际的例子:
把断点打在了start_kernel
函数,对init_task
(详见下文)进行分析,首先获取SP
指针的值
(gdb) i registers sp
**sp 0x81001ff8 0x81001ff8 <init_thread_union+8184>
通过sp
值计算出thread_info
的值
0x81001ff8&(~(0x1FFF)) = 0x81000000
根据thread_info
获取到task
结构体的地址
(gdb) p (*(struct thread_info *)(0x81000000))->task
(struct task_struct *) 0x81005ec0 <init_task>
(gdb) p &init_task
(struct task_struct *) 0x81005ec0 <init_task>
(gdb) p (*((struct task_struct *)0x81005ec0))->stack
(void *) 0x81000000 <init_thread_union>
可以看到我们根据sp
的值就推导出来thread_info
,init_task
,stack
的值。
通过反汇编文件,也能验证数值是正确的。
进程状态
进程描述符中的 state
域描述了进程的当前状态。系统中的每个进程都必然处于五种进程状态中的一种。
-
TASK_RUNING
(运行)——进程是可执行的;它或者正在执行,或者在等待执行。 -
TASK_INTERRUPTILE
(可中断)——进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一且这些条件达成,内核就会把进程状态设置为运行。 -
TASK_UNINTERRUPTIBLE
(不可中断)——与可中断的等待状态类似,但有一个例外,把信号传递到睡眠进程不能改变它的状态。 -
__TASK_TRACED
——其他进程跟踪的进程 -
__TASK_STOPPED
——进程停止执行;通常这种状态发生在接收到SIGSTOP
、SIGTSTP
、SIGTTIN
、SIGTTOU
等信号的时候。
还有两个进程状态是既可以存放在进程描述符的state
字段中,也可以存放在exit_state
字段中。从这两个字段的名称可以看出,只有当进程的执行被终止时,进程的状态才会变
为这两种状态中的一种:
-
EXIT_ZOMBIE
(僵死状态)——进程的执行被终止,但是,父进程还没有发布wait()
或waitpid()系统调用来返回有关死亡进程的信息。 -
EXIT_DEAD
(僵死撤消状态)——最终状态:由于父进程刚发出wait()
或waitpid()
系统调用,因而进程由系统删除。
进程创建
进程创建主要分为第一个任务的创建和fork()
两个章节进行描述。
第一个任务
Linux内核在启动时会有一个init_task
进程,它是系统所有进程的“鼻祖”,称为0号进程。
init_task
进程是静态定义的,其他进程在分配的时候为动态分配。
/* init_task.c */
struct task_struct init_task = INIT_TASK(init_task);
union thread_union init_thread_union __init_task_data = {
#ifndef CONFIG_THREAD_INFO_IN_TASK
INIT_THREAD_INFO(init_task)
#endif
};
/* init_task.h */
#define INIT_TASK(tsk)
{
INIT_TASK_TI(tsk)
.state = 0,
.stack = init_stack,
.usage = ATOMIC_INIT(2),
.flags = PF_KTHREAD,
.prio = MAX_PRIO-20,
.static_prio = MAX_PRIO-20,
.normal_prio = MAX_PRIO-20,
.policy = SCHED_NORMAL,
.cpus_allowed = CPU_MASK_ALL,
.nr_cpus_allowed= NR_CPUS,
.mm = NULL,
.active_mm = &init_mm,
.restart_block = {
.fn = do_no_restart_syscall,
},
.se = {
.group_node = LIST_HEAD_INIT(tsk.se.group_node),
},
.rt = {
.run_list = LIST_HEAD_INIT(tsk.rt.run_list),
.time_slice = RR_TIMESLICE,
},
.tasks = LIST_HEAD_INIT(tsk.tasks),
.thread = INIT_THREAD,
....
}
在这个结构体中,我们重点关注stack,thread
成员。
stack
成员是由init_stack
定义的,跟踪该宏的实现。
// arch/arm/include/asm/thread_info.h
#define init_stack (init_thread_union.stack)
// init/init_task.c
union thread_union init_thread_union __init_task_data = {
#ifndef CONFIG_THREAD_INFO_IN_TASK
INIT_THREAD_INFO(init_task)
#endif
};
// include/linux/init_task.h
#define __init_task_data __attribute__((__section__(".data..init_task")))
---
往这条链路分析
[arch/arm/kernel/vmlinux.lds.S]
SECTIONS
{
…
.data : AT(__data_loc) {
_data = .; /* address in memory */
_sdata = .;
/*
* first, the init task union, aligned
* to an 8192 byte boundary.
*/
INIT_TASK_DATA(THREAD_SIZE)
…
_edata = .;
}
}
[arch/arm/include/asm/thread_info.h]
#define THREAD_SIZE_ORDER 1
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
#define THREAD_START_SP (THREAD_SIZE - 8)
[include/asm-generic/vmlinux.lds.h]
#define INIT_TASK_DATA(align)
. = ALIGN(align);
*(.data..init_task)
由链接文件可以看到data
段预留了8KB的空间用于内核栈,存放在data
段的“.data..init_task”
中。
__init_task_data
宏会直接读取“.data..init_task”
段内存,并且存放了一个thread_union
联合数据结构,从联合数据结构可以看出其分布情况:开始的地方存放了struct thread_info
数据结构,顶部往下的空间用于内核栈空间。
也就说在内核初始化时用的栈空间就是0号线程栈。
后续,init_task
会逐渐迭代为swapper/x
的进程。
idle_cpu(cpu)——>fork_idle(cpu)——>init_idle(task, cpu)——>cpu_startup_entry(enum cpuhp_state state)
void init_idle(struct task_struct *idle, int cpu)
{
struct rq *rq = cpu_rq(cpu);
unsigned long flags;
raw_spin_lock_irqsave(&idle->pi_lock, flags);
raw_spin_lock(&rq->lock);
__sched_fork(0, idle);
idle->state = TASK_RUNNING;
idle->se.exec_start = sched_clock();
rcu_read_lock();
__set_task_cpu(idle, cpu); // 将进程分配到不同cpu
rcu_read_unlock();
/*
* The idle tasks have their own, simple scheduling class:
*/
idle->sched_class = &idle_sched_class;
ftrace_graph_init_idle_task(idle, cpu);
vtime_init_idle(idle, cpu);
#ifdef CONFIG_SMP
sprintf(idle->comm, "%s/%d", INIT_TASK_COMM, cpu); // 进程改名
#endif
}
可以看到该函数的参数为cpu
,也就是说在每个cpu
上都会运行一个idle
线程,最后会把init_task
改名为swapper/x
,其中x
就表示对应的cpu number。
最后执行cpu_startup_entry
函数
void cpu_startup_entry(enum cpuhp_state state)
{
/*
* This #ifdef needs to die, but it's too late in the cycle to
* make this generic (arm and sh have never invoked the canary
* init for the non boot cpus!). Will be fixed in 3.11
*/
#ifdef CONFIG_X86
/*
* If we're the non-boot CPU, nothing set the stack canary up
* for us. The boot CPU already has it initialized but no harm
* in doing it again. This is a good place for updating it, as
* we wont ever return from this function (so the invalid
* canaries already on the stack wont ever trigger).
*/
boot_init_stack_canary();
#endif
arch_cpu_idle_prepare();
cpuhp_online_idle(state);
cpu_idle_loop();
}
在cpu_idle_loop
中进行死循环,执行一些idle
相关的工作。
对于Linux内核来说,进程的“鼻祖”是idle
进程,也称为swapper
进程;但对用户空间来说,进程的“鼻祖”是init
进程,所有用户空间进程都由init
进程创建和派生。
fork描述
在Linux系统中,进程或线程是通过fork
、vfork
或clone
等系统调用来建立的。
(1)fork
:子进程是父进程的一个副本,采用了写时复制(下文会讲)的技术。
(2)vfork
:用于创建子进程,之后子进程立即调用execve
以装载新程序的情况。为了避免复制物理页,父进程会睡眠等待子进程装载新程序。现在fork采用了写时复制的技术,vfork失去了速度优势,已经被废弃。(POSIX.1 2008标准里面移除了vfork())
(3)clone
:可以精确地控制子进程和父进程共享哪些资源。这个系统调用的主要用处是可供pthread
库用来创建线程。
clone
是功能最齐全的函数,参数多,使用复杂,fork
是clone
的简化函数。
这3个系统的调用都是通过同一个函数来实现,即do_fork()
函数。
[kernel/fork.c]
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
do_fork
()函数有5个参数,具体含义如下。
clone_flags
:创建进程的标志位集合。
stack_start
:参数stack_start
只在创建线程时有意义,用来指定新线程的用户栈的起始地址。
stack_size
:参数stack_size
只在创建线程时有意义,用来指定新线程的用户栈的长度。这个参数已经废弃。
parent_tidptr
和child_tidptr
:指向用户空间中地址的两个指针,分别指向父子进程的PID。
clone_flags
定义在sched.h
文件中。
[include/uapi/linux/sched.h]
/*
* cloning flags:
*/
#define CSIGNAL 0x000000ff /* signal mask to be sent at exit */
#define CLONE_VM 0x00000100 /* 父子进程之间共享内存空间,一个进程对全局变量改动,另外一个进程也可以看到 */
#define CLONE_FS 0x00000200 /* 父子进程之间共享相同的文件系统 */
#define CLONE_FILES 0x00000400 /* 父子进程共享相同的文件描述符 */
#define CLONE_SIGHAND 0x00000800 /* 父子进程共享相同的信号处理等相关信息 */
#define CLONE_PTRACE 0x00002000 /* 父进程被trace,子进程也同样被trace */
#define CLONE_VFORK 0x00004000 /* 父进程被挂起,直到子进程释放了虚拟内存资源 */
#define CLONE_PARENT 0x00008000/* 新进程和创建它的进程是兄弟关系,而不是父子关系 */
#define CLONE_THREAD 0x00010000/* 父子进程共享相同的线程群*/
…
fork
实现:
`do_fork(SIGCHLD, 0, 0, NULL, NULL);`
vfork
实现:
`do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL);`
clone
实现:
`do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr);`
内核线程:
`do_fork(flags|CLONE_VM|CLONE_UNTRACED, (unsigned long)fn, (unsigned long)arg, NULL, NULL);`
pthread
实现:
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
| CLONE_SIGHAND | CLONE_THREAD
| CLONE_SETTLS | CLONE_PARENT_SETTID
| CLONE_CHILD_CLEARTID
| 0);
TLS_DEFINE_INIT_TP (tp, pd);
if (__glibc_unlikely (ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS,
clone_flags, pd, &pd->tid, tp, &pd->tid)
== -1))
fork
只使用SIGCHLD
标志位,在子进程终止后发送SIGCHLD
信号通知父进程。
fork
是重量级调用,为子进程建立了一个基于父进程的完整副本,然后子进程基于此运行。
vfork
的实现比fork多了两个标志位,分别是CLONE_VFORK
和CLONE_VM
。CLONE_VFORK
表示父进程会被挂起,直至子进程释放虚拟内存资源。CLONE_VM
表示父子进程运行在相同的内存空间中。clone
用于创建线程,并且参数通过寄存器从用户空间传递下来,通常会指定新的栈地址。
为了减少工作量采用写时复制技术
(copy on write,COW),子进程只复制父进程的页表,不会复制页面内容,也就是说子进程和父进程的物理地址相同,虚拟地址不同。当子进程需要写入新内容时才触发写时复制机制,为子进程创建一个副本,此时,物理地址和虚拟地址都不同。
fork()
的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符.
退出线程
进程退出分两种情况:进程主动退出和终止进程。
主动退出:
(1)exit()
用来线程退出。
(2)exit_group()
用来使一个线程组的所有线程退出。
终止进程:
终止进程是通过给进程发送信号实现的,Linux内核提供了发送信号的系统调用。
(1)kill
用来发送信号给进程或者进程组;
(2)tgkill
用来发送信号给线程,参数tgid是线程组标识符,参数tid是线程标识符。
当进程退出的时候,根据父进程是否关注子进程退出事件,处理存在如下差异。
(1)如果父进程关注子进程退出事件,那么进程退出时释放各种资源,只留下一个空的进程描述符,变成僵尸进程,发送信号SIGCHLD
通知父进程,父进程在查询进程终止(调用wait
或waitpid
)的原因以后回收子进程的进程描述符。
(2)如果父进程不关注子进程退出事件,那么进程退出时释放各种资源,释放进程描述符,自动消失。
Linux内核提供了2个系统调用来等待子进程的状态改变,状态改变包括:子进程终止,信号SIGSTOP
使子进程停止执行,或者信号SIGCONT
使子进程继续执行。
(1)pid_t waitpid(pid_t pid, int *wstatus, int options);
(2)int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
总结来说,父进程可以通过wait()
或waitpid()
查询子进程是否终结,这其实使得父进程拥有了等待子进程执行完毕的能力。子进程退出执行后被设置为僵死状态,此时仍保留子进程的进程描述符,直到父进程调用wait()
或waitpid()
后,才会回收子进程的进程描述符。
改动sample1来进行验证下:
#include<sys/types.h>
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<errno.h>
int main()
{
printf("this is fork sample %dn",getpid());
pid_t pid;/*pid 进程id号*/
pid=fork();/*创建一个新进程*/
printf("pid:%dn",pid);
if(pid==0) /*返回0为子进程*/
{
printf("Return pid is %dn",pid);
printf("This is son process! pid is:%dn",getpid());
}
else if(pid>0)/*返回大于0为父进程*/
{
printf("Return pid is %dn",pid);
printf("This is parent process! pid is:%dn",getpid());
// waitpid(pid,NULL,0);/*等待子进程退出*/
while(1);
}
else
{
perror("fork() error!");
exit;
}
}
可以看到184的父进程是183。由于父进程没有等待子进程结束,所以184目前处于僵死状态
子进程退出以后需要父进程回收进程描述符,如果父进程先退出,子进程成为“孤儿”,谁来为子进程回收进程描述符呢?父进程退出时需要给子进程寻找一个“领养者”,按照下面的顺序选择领养“孤儿”的进程。
(1)如果进程属于一个线程组,且该线程组还有其他线程,那么选择任意一个线程。
(2)选择最亲近的充当“替补领养者”的祖先进程。进程可以使用系统调用prctl(PR_SET_CHILD_SUBREAPER)把自己设置为“替补领养者”(subreaper)。
(3)选择进程所属的进程号命名空间中的1号进程。
这一章终于写完了, 不得不说Linux的内容太多了,本章的描写也参考了很多资料,导致写得特别慢,不过会坚持更新下去的。
原文始发于微信公众号(TreeNewBeer):Linux内核设计与实现——线程
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/115090.html