知道了Linux进程、线程、线程池和协程的由来,但进程和线程是怎么实现的呢?进程和线程如何查看呢?
先有个整体的概念:应用层调用封装的libc库函数进行系统调用syscall,进程和线程最终都会调用do_fork函数产生一个task_struct结构,schedule函数会通过时钟中断来触发调度,如下图。
创建进程
1. Linux系统调用fork和vfork来创建进程,参考内核源码/linux-4.5.2/kernel/fork.c:
fork:
SYSCALL_DEFINE0(fork)
{
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
vfork:
SYSCALL_DEFINE0(vfork)
{
return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0,0, NULL, NULL, 0);
}
由源代码可知fork和vfork都调用do_fork函数,仅传入的clone_flags参数不同见表如下。clone_flags重要参数做了解:CLONE_NEWNS表示在新的命名空间启动子进程();CLONE_VM子进程和父进程运行于相同的内核空间;CLONE_VFORK父进程被挂起直至子进程释放虚拟内存资源;CLONE_FS子进程与父进程共享相同的文件系统。
2. 重点看下进程创建的核心do_fork函数,do_fork函数的整个执行流程如图,比较关键的是调用copy_process函数,成功后创建子进程后就可以获取到pid。
看完图有木有发现fork和vfork的一个区别:(如果是vfork父进程休眠则等待子进程将其唤醒)vfork场景景下父进程会先休眠,等唤醒子进程后,再唤醒父进程。这样做有什么好处呢?个人认为在vfork场景下,子进程被创建出来时,是和父进程共享地址空间的(可以进行验证),并且它是只读的,只有执行exec创建新的内存程序映象时才会拷贝父进程的数据创建新的地址空间,假如这个时候父进程还在运行,就有可能产生脏数据或者发生死锁。在还没完全让子进程运行起来的时候,让其父进程休息是个比较好的办法。
3. 接着重点看copy_process函数的工作流程如下图。
主要参数说明如下:
1)copy_semundo(clone_flags, p):拷贝系统安全相关的数据给子进程,如果clone_flags设置了CLONE_SYSVSEM,则复制父进程的sysvsem.undo_list到子进程;否则子进程的tsk->sysvsem.undo_list为NULL。
2)copy_files(clone_flags, p):如果clone_flags设置了CLONE_FILES,则父子进程共享相同的文件句柄;否则将父进程文件句柄拷贝给子进程。
3)copy_fs(clone_flags, p):如果clone_flags设置了CLONE_FS,则父子进程共享相同的文件系统结构体对象;否则调用copy_fs_struct拷贝一份新的fs_struct结构体,但是指向的还是进程0创建出来的fs,并且文件系统资源是共享的。
4)copy_sighand(clone_flags, p):如果clone_flags设置了CLONE_SIGHAND,则增加父进程的sighand引用计数;否则(创建的必定是子进程)将父进程的sighand_struct复制到子进程中。
5)copy_signal(clone_flags, p):如果clone_flags设置了CLONE_THREAD(是线程),则增加父进程的sighand引用计数;否则(创建的必定是子进程)将父进程的sighand_struct复制到子进程中。
6)copy_mm(clone_flags, p)内存地址空间的拷贝:如果clone_flags设置了CLONE_VM,则将子进程的mm指针和active_mm指针都指向父进程的mm指针所指结构;否则将父进程的mm_struct结构复制到子进程中,然后修改当中属于子进程而有别于父进程的信息(如页目录)。
7)copy_io(clone_flags, p):如果clone_flags设置了CLONE_IO,则子进程的tsk->io_context为current->io_context;否则给子进程创建一份新的io_context。
8)copy_thread_tls(clone_flags, stack_start, stack_size, p, tls)栈的分配,在创建子进程的过程中,子进程的内核栈空间是随进程同时分配的。
创建内核线程
Linux中,create_kthread来创建内核线程:
static void create_kthread(struct kthread_create_info * create)
{
int pid;
pid = kernel_thread(kthread, create, CLONE_FS | CLONE_FILES | SIGCHLD);
}
kernel_thread也会和fork一样最终调用_do_fork函数,该函数的实现在/linux-4.5.2/kernel/fork.c文件中:
pid_t kernel_thread(int (* fn)(void *), void *arg, unsigned long flags)
{
return _do_fork(flags|CLONE_VM| CLONE_UNTRACED, (unsigned long)fn, (unsigned long)arg, NULL, NULL, 0);
}
查看内核线程:ps -fax
查询结果显示在[]号中的进程为内核线程:
# ps -fax
PID TTY STAT TIME COMMAND
2 ? S 0:34 [kthreadd]
3 ? S 1276:07 \_ [ksoftirqd/0]
创建线程:用户线程库pthread
在libc库函数中,pthread库用于创建用户线程,其代码在libc目录下的nptl中。
libc库考虑不同系统兼容性问题,里面有一堆条件编译信息,这里忽略了这些信息,简单地调用pthread库创建一个线程来测试:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void* test_fn(void* arg)
{
printf("hello pthread.\n");
sleep(5);
return((void *)0);
}
int main(int argc, char * * argv)
{
pthread_t id;
int ret;
ret = pthread_create(&id, NULL, test_fn, NULL);
if(ret ! = 0)
{
printf("create pthread error! \n");
exit(1);
}
printf("in main process.\n");
pthread_join(id, NULL);
return 0;
}
gcc命令生成可执行文件:gcc -g -lpthread -Wall -o test_pthread test_pthread.c
strace跟踪系统调用strace ./test_pthread.c
mmap(NULL, 8392704, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7fb6ade8a000
brk(0)= 0x93d000
brk(0x95e000)= 0x95e000
mprotect(0x7fb6ade8a000, 4096, PROT_NONE)= 0
clone(child_stack=0x7fb6ae689ff0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7fb6ae68a9d0, tls=0x7fb6ae68a700,child_tidptr=0x7fb6ae68a9d0) = 6186
从上面strace产生的结果,可以看出pthread创建线程的流程如下:
1)mmap分配用户空间的栈大小。
2)mprotect设置内存页的保护区(大小为4KB),这个页面用于监测栈溢出,如果对这片内存有读写操作,那么将会触发一个SIGSEGV信号。
3)通过clone调用创建线程。
通过对pthread分析,也可以知道用户线程的堆栈可以通过mmap从用户空间自行分配。
用户线程可以自己指定用户栈,地址空间和父进程共享,内核线程则只有和内核共享的同一个栈,同一个地址空间。
参考资料:陈科的《Linux内核分析及应用》1.2 Linux进程和线程的实现