gon1.4 线程创建的Linux实现
我们知道,Linux的线程实现是在核外进行的,核内提供的是创建进程的接口do_fork()。内核提供了两个系统调用__clone()和fork
(),最终都用不同的参数调用do_fork()核内API。当然,要想实现线程,没有核心对多进程(其实是轻量级进程)共享数据段的支持是不行的,因
此,do_fork()提供了很多参数,包括CLONE_VM(共享内存空间)、CLONE_FS(共享文件系统信息)、CLONE_FILES(共享文
件描述符表)、CLONE_SIGHAND(共享信号句柄表)和CLONE_PID(共享进程ID,仅对核内进程,即0号进程有效)。当使用fork系统
调用时,内核调用do_fork()不使用任何共享属性,进程拥有独立的运行环境,而使用pthread_create()来创建线程时,则最终设置了所
有这些属性来调用__clone(),而这些参数又全部传给核内的do_fork(),从而创建的"进程"拥有共享的运行环境,只有栈是独立的,由
__clone()传入。
Linux线程在核内是以轻量级进程的形式存在的,拥有独立的进程表项,而所有的创建、同步、删除等操作都在核外pthread库中进行。
pthread
库使用一个管理线程(__pthread_manager(),每个进程独立且唯一)来管理线程的创建和终止,为线程分配线程ID,发送线程相关的信号
(比如Cancel),而主线程(pthread_create())的调用者则通过管道将请求信息传给管理线程。
fork, vfork,
clone,pthread_create,kernel_thread
fork,vfork,clone,都是系统调用,以前还以为是前面两个是clone的封装,实际上前三个都是系统调用,pthread_create是对clone的封装,kernel_thread用于创建内核线程
fork 在内核中调用
do_fork(SIGCHLD, regs.esp, ®s,
0, NULL, NULL)
vfork:
do_fork(CLONE_VFORK |
CLONE_VM | SIGCHLD, regs.esp, ®s, 0, NULL, NULL)
clone:
do_fork(clone_flags, newsp,
®s, 0, parent_tidptr, child_tidptr)
其中 clone_flags = regs.ebx;
newsp = regs.ecx;
parent_tidptr = (int __user *)regs.edx;
child_tidptr = (int __user *)regs.edi;
pthread_create:
pid =
__clone(__pthread_manager_event,
(void **) __pthread_manager_thread_tos,
CLONE_VM | CLONE_FS | CLONE_FILES |
CLONE_SIGHAND,
(void *)(long)manager_pipe[0]);
kernel_thread:
do_fork(flags |
CLONE_VM | CLONE_UNTRACED, 0, ®s, 0, NULL,
NULL)
在执行这个函数之前,在栈中对regs作了初始化,把自己的参数塞进起,并设regs.eip=kernel_thread_helper,具体流程参见前一篇文章。
可见他们的不同主要在于flag的不同,为什么前两个都有SIGCHLD,把保护用户态寄存器的地址传递是为什么?进程的到底是怎么切换的?
先看do_fork,首先分配一个PID,调用copy_process()进行具体的拷贝,如果flag里有CLONE_VFORK,将父进程放入一个等待队列,所以子进程先运行。
copy_process()
This creates a new process as a copy of the old
one, but does not actually start it yet.It copies the registers,
and all the appropriate parts of the process environment.
p = dup_task_struct(current); 为子进程分配task_struct和内核stack.
然后copy_files(),copy_fs()进行拷贝。
copy_thread,把用户传进来的通用寄存器进行拷贝给子进程并设置,这儿很关键
childregs = task_pt_regs(p);
*childregs = *regs;
childregs->eax = 0;
childregs->esp = esp;
p->thread.esp = (unsigned long) childregs;
p->thread.esp0 = (unsigned long) (childregs+1);
p->thread.eip = (unsigned long) ret_from_fork;
可见传入的用户态通用寄存器附给了子进程,并置eax=0所以返回的pid是0,估计是pt_regs里没有esp,所以单独赋值了。task_struct的thread字段记录了进程特定于cpu的信息,但切换到子进程的时候,就把thread中esp,eip弹出,所以子进程就可以通过ret_from_fork返回运行了。
而ret_from_fork
asmlinkage void ret_from_fork(void)
__asm__("ret_from_fork");
不明白。
至于前面说道的SIGCHLD,估计是设置子进程挂了的时候给父进程发个SIGCHLD信号。也没看到在哪儿设。
所谓创建一个进程,就是创建task_struct和内核stack,并把stack里的pt_regs设置下,返回的时候好恢复到调用点,并选择性的继承父进程的资源。
内核线程
内核线程和用户级线程的区别在于内核线程没有独立的地址空间(实际上它的mm指针被设置为NULL).它也可以被调度也可以被抢占。内核线程也只能由其他内核线程创建。方法如下:int
kernel_thread(int (*fn)(void *), void *arg, unsigned long
flags).新的任务也是通过像普通的clone()系统调用传递特定的flags参数而创建的。上面函数返回时,父进程退出,并返回一个子线程task_struct的指针。子进程开始运行fn指向的函数,arg是运行时需要用到的参数。一个特殊的clone标志CLONE_KERNEL定义了内核线程常用到参数标志:CLONE_FS,
CLONE_FILES, CLONE_SIGHAND.大部分的内核线程把这个标志传递给它们的flags参数。
针对线程模型的两大意义,分别开发出了核心级线程和用户级线程两种线程模型,分类的标准主要是线程的调度者在核内还是在核外。前者更利于并发使用多处理器的资源,而后者则更多考虑的是上下文切换开销。在目前的商用系统中,通常都将两者结合起来使用,既提供核心线程以满足smp系统的需要,也支持用线程库的方式在用户态实现另一套线程机制,此时一个核心线程同时成为多个用户态线程的调度者。正如很多技术一样,"混合"通常都能带来更高的效率,但同时也带来更大的实现难度,出于"简单"的设计思路,Linux从一开始就没有实现混合模型的计划,但它在实现上采用了另一种思路的"混合"。
在线程机制的具体实现上,可以在操作系统内核上实现线程,也可以在核外实现,后者显然要求核内至少实现了进程,而前者则一般要求在核内同时也支持进程。核心级线程模型显然要求前者的支持,而用户级线程模型则不一定基于后者实现。这种差异,正如前所述,是两种分类方式的标准不同带来的。
当核内既支持进程也支持线程时,就可以实现线程-进程的"多对多"模型,即一个进程的某个线程由核内调度,而同时它也可以作为用户级线程池的调度者,选择合适的用户级线程在其空间中运行。这就是前面提到的"混合"线程模型,既可满足多处理机系统的需要,也可以最大限度的减小调度开销。绝大多数商业操作系统(如Digital
Unix、Solaris、Irix)都采用的这种能够完全实现POSIX1003.1c标准的线程模型。在核外实现的线程又可以分为"一对一"、"多对一"两种模型,前者用一个核心进程(也许是轻量进程)对应一个线程,将线程调度等同于进程调度,交给核心完成,而后者则完全在核外实现多线程,调度也在用户态完成。后者就是前面提到的单纯的用户级线程模型的实现方式,显然,这种核外的线程调度器实际上只需要完成线程运行栈的切换,调度开销非常小,但同时因为核心信号(无论是同步的还是异步的)都是以进程为单位的,因而无法定位到线程,所以这种实现方式不能用于多处理器系统,而这个需求正变得越来越大,因此,在现实中,纯用户级线程的实现,除算法研究目的以外,几乎已经消失了。
一.linux进程,轻量级进程,线程
最初的进程定义都包含程序、资源及其执行三部分,其中程序通常指代码,资源在操作系统层面上通常包括内存资源、IO资源、信号处理等部分,而程序的执行通常理解为执行上下文,包括对cpu的占用,后来发展为线程。在线程概念出现以前,为了减小进程切换的开销,操作系统设计者逐渐修正进程的概念,逐渐允许将进程所占有的资源从其主体剥离出来,允许某些进程共享一部分资源,例如文件、信号,数据内存,甚至代码,这就发展出轻量进程的概念。
Linux 线程在核内是以轻量级进程的形式存在的,拥有独立的进程表项,而所有的创建、同步、删除等操作都在核外pthread库中进行,
而调度交给内核处理。轻量级进程和普通进程的区别在于:前者没有独立的用户空间(内核态线程无用户空间,用户态线程共享用户空间),而普通进程有独立的内存空间
二.实现
Linux的进程,线程实现是在核外进行的,核内提供的是创建进程的接口do_fork()。内核提供了
三个系统调用__clone()和fork()以及vfort(),最终都用不同的参数调用do_fork()核内API。
do_fork()
提供了很多参数,包括CLONE_VM(共享内存空间)、CLONE_FS(共享文件系统信息)、CLONE_FILES(共享文件描述符表)、
CLONE_SIGHAND(共享信号句柄表)和CLONE_PID(共享进程ID,仅对核内进程,即0号进程有效)。
Linux下不管是多线程还是多进程编程,linux通过clone()系统调用实现产生进程或者线程。无论是fork(),还是vfork(),__clone()最后都根据各自需要的参数标志去调用clone().然后有clone()去调用do_fork().这样一说,最终都是用do_fork实现的多进程编程,只是进程创建时的参数不同,从而导致有不同的共享环境。
Clone()
clone函数功能强大,带了众多参数。clone可以让你有选择性的继承父进程的资源,你可以选择想vfork一样和父进程共享一个虚存空间,从而使创造的是线程,你也可以不和父进程共享,你甚至可以选择创造出来的进程和父进程不再是父子关系,而是兄弟关系。先有必要说下这个函数的结构
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg);
这里fn是函数指针,我们知道进程的4要素,这个就是指向程序的指针,就是所谓的“剧本",
child_stack明显是为子进程分配系统堆栈空间(在linux下系统堆栈空间是2页面,就是8K的内存,其中在这块内存中,低地址上放入了值,这个值就是进程控制块task_struct的值),flags就是标志用来描述你需要从父进程继承那些资源,
arg就是传给子进程的参数)。下面是flags可以取的值
CLONE_PARENT
创建的子进程的父进程是调用者的父进程,新进程与创建它的进程成了“兄弟”而不是“父子”
CLONE_FS子进程与父进程共享相同的文件系统,包括root、当前目录、umask
CLONE_FILES子进程与父进程共享相同的文件描述符(file
descriptor)表
CLONE_NEWNS
在新的namespace启动子进程,namespace描述了进程的文件hierarchy
CLONE_SIGHAND 子进程与父进程共享相同的信号处理(signal
handler)表
CLONE_PTRACE 若父进程被trace,子进程也被trace
CLONE_VFORK父进程被挂起,直至子进程释放虚拟内存资源
CLONE_VM子进程与父进程运行于相同的内存空间
CLONE_PID 子进程在创建时PID与父进程一致
CLONE_THREAD Linux
2.4中增加以支持POSIX线程标准,子进程与父进程共享相同的线程群
fork(),vfork(),_clone(),exec()
1.fork()
fork
创造的子进程复制了父亲进程的资源,子进程是父进程的一个拷贝。具体说,子进程从父进程那得到了数据段和堆栈段,但不是与父进程共享而是单独分配内存.
在Linux下使用了一种叫做写时拷贝(copy-on-write)页实现。这种技术原理是:内存并不复制整个进程地址空间,而是让父进程和子进程共享同一拷贝,只有在需要写入的时候,数据才会被复制当使用fork系统调用产生多进程时,内核调用do_fork()不使用任何共享属性,进程拥有独立的运行环境。
调用clone时候flag指定为SIGCHLD和清除所有CLONE_XX标志调用clone().然后有clone()去调用do_fork().这样一说,我想大家明白我的意思了,问题的关键纠结于do_fork(),它定义在kernel/fork.c中,完成了大部分工作,该函数调用copy_process()函数,然后让进城开始运行,copy_precess()函数完成的工作很有意思:
1.调用dup_task_struct()为新进程创建一个内核栈,它的定义在kernel/fork.c文件中。该函数调用copy_process()函数。然后让进程开始运行。从函数的名字dup就可知,此时,子进程和父进程的描述符是完全相同的。
2.检查这个新创建的的子进程后,当前用户所拥有的进程数目没有超过给他分配的资源的限制。
3.现在,子进程开始使自己与父进程区别开来。进程描述符内的许多成员都要被清0或设为初始值。
4.接下来,子进程的状态被设置为TASK_UNINTERRUPTIBLE以保证它不会投入运行。
5.调用copy_flags()以更新task_struct的flags成员,表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0。表明进程还没有调用exec函数的PF_FORKNOEXEC标志。
6.调用get_pid()为新进程获取一个有效的PID.
7.根据传递给clone()的参数标志,拷贝或共享打开的文件,文件系统信息,信号处理函数。进程地址空间和命名空间等。一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里.
8.让父进程和子进程平分剩余的时间片
9.最后,作扫尾工作并返回一个指向子进程的指针。
经过上面的操作,再回到do_fork()函数,如果copy_process()函数成功返回。新创建的子进程被唤醒并让其投入运行。内核有意选择子进程先运行。因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销。如果父进程首先执行的话,有可能会开始向地址空间写入。
2.vfork()
vfork创建新进程的主要目的在于用exec函数执行另外的程序,实际上,在没调用exec或exit之前子进程的运行中是与父进程共享数据段的。在vfork调用中,子进程先运行,父进程挂起,直到子进程调用exec或exit,在这以后,父子进程的执行顺序不再有限制。调用clone时候flag指定为SIGCHLD和CLONE_VM,
CLONE_VFORK。
vfork()不拷贝父进程的页表项。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec(),子进程不能向地址空间写入。按照刚才的方法,分析一下vfork(),它是通过向clone()系统调用传递一个特殊标志来进行的,过程如下:
1.在调用copy_process时,task_struct的vfor_done成员被设置为NULL
2.在执行do_fork()时,如果给定特别标志,则vfork_done会指向一个特殊地址。
3.子进程开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfork_done指针向它发送信号。
4.在调用mm_release()时,该函数用于进程退出内存地址空间,如果vfork_done不为空,会向父进程发送信号。
5.回到do_fork(),父进程醒来并返回。
3.__clone()
当使用pthread_create()来创建线程时,则最终设置了所有这些属性来调用__clone(),而这些参数又全部传给核内的do_fork(),从而创建的"进程"拥有共享的运行环境,只有栈是独立的,由
传入。我们说在linux中,线程仅仅是一个使用共享资源的轻量级进程。调用clone时候flag指定为CLONE_VM
| CLONE_FS | CLONE_FILES | CLONE_SIGHAND。
4.__clone()
该函数从一个bin文件加载一个app,它不创建新的进程,只是把当前替换成新的bin程序文件,并执行它。因为调用e
x e c并不创建新进程,所以前后的进程I D并未改变。e x e c只是用另一个新程序替换了当前进程的正文、数据、堆和栈段。
Linux内核只提供了轻量进程的支持,限制了更高效的线程模型的实现,但Linux着重优化了进程的调度开销,一定程度上也弥补了这一缺陷。目前最流行的线程机制LinuxThreads所采用的就是线程-进程"一对一"模型,调度交给核心,而在用户级实现一个包括信号处理在内的线程管理机制。Linux-LinuxThreads的运行机制正是本文的描述重点
浅析linux中fork函数
作者:吴新武,华清远见嵌入式学院讲师。
Linux通过clone()系统调用实现fork()、vfork()和__clone()库函数创建新的进程,这个调用通过一系列的参数标志来指明父子进程的共享资源,最终将各自的参数标志位传递给clone,由clone()去调用do_fork()来实现创建新的进程的目的。
do_fork的实现源码在kernel/fork.c文件中,其主要的作用就是复制原来的进程成为另一个新的进程,它完成了整个进程的创建过程。do_fork()的实现主要由以下5个步骤,在分析代码之前,先了解以下do_fork()函数的参数的含义,其参数的含义如下。
clone_flags:该标志位的4个字节分为两部分。最低的一个字节为子进程结束时发送给父进程的信号代码,通常为SIGCHLD;剩余的三个字节则是各种clone标志的组合。通过clone标志可以有选择的对父进程的资源进行复制。例如CLONE_VM表示共享内存描述符合所有的页表;
CLONE_FS共享根目录和当前工作目录所在的表以及权限掩码。
statck_start:子进程用户态堆栈的地址;
regs:指向pt_regs结构体的指针。当系统发生系统调用,即用户进程从用户态切换到内核态时,该结构体保存通用寄存器中的值,并被存放于内核态的堆栈中;
stack_size:未被使用,通常被赋值为0;
parent_tidptr:父进程在用户态下pid的地址,该参数在CLONE_PARENT_SETTID标志被设定时有意义;
child_tidptr:子进程在用户态下pid的地址,该参数在CLONE_CHILD_SETTID标志被设定时有意义。
函数原型及实现为:
long do_fork(unsigned long clone_flags,unsigned
long stack_start, struct pt_regs *regs,unsigned long stack_size,int
__user *parent_tidptr, int __user *child_tidptr)
{
struct
task_struct *p;
p
= copy_process(clone_flags, stack_start, regs, stack_size,
child_tidptr,
NULL, trace); (1)
if
(!IS_ERR(p)) {
if
(clone_flags & CLONE_VFORK) {
p->vfork_done
= &vfork;
init_completion(&vfork);
}
……
}
一、首先调用copy_process()函数
copy_process()函数实现了进程的大部分拷贝工作。
static struct task_struct *copy_process(unsigned
long clone_flags, unsigned long stack_start,struct pt_regs
*regs,unsigned long stack_size, int __user *child_tidptr, struct
pid *pid,int trace)
{
//对传入的clone_flag进行检查
//为新进程创建一个内核栈、thread_info结构和task_struct;其值域当前进程的值完全相同(父子进程的描述符此时也相同)
p = dup_task_struct(current);
//判断是否超出进城用户可以拥有的总进城数量,检查是否有权对指定的资源进行操作
if
(atomic_read(&p->real_cred->user->processes)
>=
task_rlimit(p,
RLIMIT_NPROC)) {
if
(!capable(CAP_SYS_ADMIN) && !capable(CAP_SYS_RESOURCE)
&&
p->real_cred->user
!= INIT_USER)
goto
bad_fork_free;
}
//在task_struct结构中有一个指针user,该指针指向一个user_struct结构,一个用户的多个进程可以通过user指针共享该用户的资源信息,该结构定义在include/linux/sched.h中,
retval = copy_creds(p, clone_flags);
//copy_creds函数中调用:
//检查创建的进程是否超过了系统进程总量
if (nr_threads >= max_threads)
goto
bad_fork_cleanup_count;
//获得进程执行域
if
(!try_module_get(task_thread_info(p)->exec_domain->module))
goto
bad_fork_cleanup_count;
//调用copy_flags函数更新task_struct结构中flags成员。表明进程是否拥有超级用户权限的PF_SUPERPPRIV标志被清除,表明进程还没有exec()的PF_FORKNOEXEC被设置
copy_flags(clone_flags, p);
//根据clone的参数标志,拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间,代码如下图所示。
//为新进程获取一个有效的PID,调用pid =
alloc_pidmap();紧接着使用alloc_pidmap函数为这个新进程分配一个pid。由于系统内的pid是循环使用的,所以采用位图方式来管理,用每一位(bit)来标示该位所对应的pid是否被使用。分配完毕后,判断pid是否分配成功。
pid = alloc_pid(p->nsproxy->pid_ns);
//父子进程平分共享的时间片
sched_fork(p, clone_flags);
//返回子进程的指针。
return p;
}
再回到do_fork函数,如果copy_process函数成功返回,新创建的子进程被唤醒并投入运行。内核有意选择子进程首先执行。因为一般子进程都会马上调用exec函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能开始向地址空间写入。
二、init_completion(&vfork);
如果clone_flags包含CLONE_VFORK标志,那么将进程描述符中的vfork_done字段指向这个完成量,之后再对vfork完成量进行初始化。完成量的作用是,直到任务A发出信号通知任务B发生了某个特定事件时,任务B才会开始执行;否则任务B一直等待。我们知道,如果使用vfork系统调用来创建子进程,那么必然是子进程先执行。究其原因就是此处vfork完成量所起到的作用:当子进程调用exec函数或退出时就向父进程发出信号。此时,父进程才会被唤醒;否则一直等待。此处的代码只是对完成量进行初始化,具体的阻塞语句则在后面的代码中有所体现。
三、检查子进程是否设置了CLONE_STOPPED标志。
设置了CLONE_STOPPED标志通过sigaddset函数为子进程增加挂起信号。signal对应一个unsigned
long类型的变量,该变量的每个位分别对应一种信号。具体的操作是,将SIGSTOP信号所对应的那一位置1。
如果子进程并未设置CLONE_STOPPED标志,那么通过wake_up_new_task函数使得父子进程之一优先运行;否则,将子进程的状态设置为TASK_STOPPED。
四、检查CLONE_VFORK标志被设置
如果CLONE_VFORK标志被设置,则通过wait操作将父进程阻塞,直至子进程调用exec函数或者退出。
五、返回pid
return nr; //其中nr最后一次赋值为:nr =
task_pid_vnr(p);即子进程的pid号。
至此,fork函数的系统调用过程结束,子进程和父进程各返回一次,子进程返回值为0,父进程返回值为子进程的pid号。应用程序可通过fork的返回值来判断是在子进程中还是父进程中,从而实现多进程程序的编写。