当前位置: 首页 > 科技观察

说说Linux中线程和进程的联系和区别!

时间:2023-03-22 10:30:22 科技观察

大家好,我是飞哥!关于进程和线程,在Linux中是一对非常核心的概念。但是进程和线程到底是什么关系,有什么区别,很多人还没有搞清楚。在网上关于进程和线程的讨论中,很多都是关注这两者的区别。但实际上,在Linux上,进程和线程的相同点远大于不同点。Linux下的线程甚至被称为轻量级进程。今天就从Linux内核实现的角度,给大家深度对比一下进程和线程。1、线程创建方式Redis6.0及以上版本也开始支持使用多线程来提供核心服务。让我们以它为例。Redis主线程启动后,会调用initThreadedIO创建多个io线程。Redis源码地址:https://github.com/redis/redisvoidinitThreadedIO(void){for(inti=0;icreate_thread。其中,create_thread函数比较重要。它设置创建线程时使用的各种标志。staticintcreate_thread(structpthread*pd,...){intclone_flags=(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGNAL|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID|CLONE_SYSVSEM|0);intres=do_clone(pd,attr,clone_flags,start_thread,STACK_VARIABLES_ARGS,1);...}在上面的代码中,传入参数中的flags是非常关键的。这里我们首先知道传入了CLONE_VM、CLONE_FS、CLONE_FILES等标志位,后面会讲到内核中对这些参数的特殊处理。下面的do_clone最终会调用一段汇编程序,在汇编中进入clone系统调用,然后进入内核进行处理。//file:sysdeps/unix/sysv/linux/i386/clone.SENTRY(BP_SYM(__clone))...movl$SYS_ify(clone),%eax...二、线程在内核中的表示介绍在在开始的创建过程之前,先给大家展示一下内核中代表线程的数据结构。文章开头我说过进程和线程的相同点远大于不同点。主要原因是在Linux中,进程和线程都被抽象为task任务,在源码中用task_struct结构实现。我们看一下task_struct的具体定义,它位于include/linux/sched.h中。structtask_struct{易失性长状态;pid_tpid;pid_ttgid;结构task_struct__rcu*parent;结构list_head孩子;结构list_head兄弟姐妹;结构task_struct*group_leader;intprio,static_prio,normal_prio;无符号整数rt_priority;结构mm_struct*mm,*active_mm;结构fs_struct*fs;结构文件_结构*文件;结构nsproxy*nsproxy;...}对于线程,所有字段都和进程一样(用结构体表示)。包括状态、pid、任务树关系、地址空间、文件系统信息、打开文件信息等字段,线程也都有。这就是我之前所说的。进程和线程之间的相同点远大于不同点。它们本质上是同一个东西,一个task_struct!因为进程线程是如此的相似,Linux下的线程还有一个名字叫轻量级进程。至于轻在哪,后面再说。这里稍微说一下pid和tgid这两个字段。在Linux中,每个task_struct都需要唯一标识,它的pid就是一个唯一的标识号。structtask_struct{......pid_tpid;pid_ttgid;}对于一个进程,这个pid就是我们通常所说的进程pid。对于线程,我们假设一个进程下创建了多个线程。那么每个线程的pid都不一样。但是我们一般需要记录线程属于哪个进程。这时候tgid就派上用场了,它所属的进程ID由tgid字段表示。这样内核就可以通过tgid知道线程属于哪个进程。3.线程创建过程要了解进程和线程的区别,让我们仔细看看线程创建过程。3.1回顾进程的创建其实在创建进程线程的时候,使用的函数看起来是不一样的。但实际上,底层实现最终还是使用同一个函数来实现的。下面简单回顾一下创建进程时fork系统调用的源码。fork调用主要执行do_fork函数。注意:fork函数调用do_fork传递的参数分别为SIGCHLD、0、0、NULL、NULL。SYSCALL_DEFINE0(fork){returndo_fork(SIGCHLD,0,0,NULL,NULL);}do_fork函数调用copy_process完成进程创建。longdo_fork(...){structtask_struct*p;p=copy_process(clone_flags,...);...}3.2线程创建我们在本文第一节中介绍过,lib库函数pthread_create会调用到clone系统Call,传入一组flags。//file:nptl/sysdeps/pthread/createthread.cstaticintcreate_thread(structpthread*pd,...){intclone_flags=(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGNAL|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID|CLONE_SYS0)int|扫描电镜;=do_clone(pd,attr,clone_flags,...);...}好的,我们找到了克隆系统调用的实现。//file:kernel/fork.cSYSCALL_DEFINE5(clone,......){returndo_fork(clone_flags,newsp,0,parent_tidptr,child_tidptr);}同样的,do_fork函数还是会执行到copy_process完成实际创建。3.3进程线程创建的异同可见一斑。与用于创建进程的fork系统调用相比,创建线程的clone系统调用与fork相差无几。也是使用了内核中的do_fork函数,最后去copy_process完成创建。但是创建过程的区别在于调用do_fork时传入的clone_flags中的flags是不一样的!.创建进程时的标志:创建线程时只有一个SIGCHLD标志:包括CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_SIGNAL、CLONE_SETTLS、CLONE_PARENT_SETTID、CLONE_CHILD_CLEARTID、CLONE_SYSVSEM。关于这几个flags的含义,我们会选择几个关键的来简单介绍一下,后面在介绍do_fork的细节时会再讲到。CLONE_VM:新任务与父进程共享地址空间CLONE_FS:新任务与父进程共享文件系统信息CLONE_FILES:新任务与父进程共享文件描述符表这些标志对task_struct会有什么影响,让我们看看接下来会发生什么。4.do_fork系统调用揭秘本节我们从动态的角度来看线程的创建过程。正如我们前面看到的,进程和线程的创建都是通过调用内核中的do_fork函数来执行的。在do_fork的实现中,核心是一个copy_process函数,通过复制父进程(线程)生成一个新的task_struct。//file:kernel/fork.clongdo_fork(unsignedlongclone_flags,...){//复制一个task_structoutofstructtask_struct*p;p=copy_process(clone_flags,stack_start,stack_size,child_tidptr,NULL,trace);//子任务加入就绪队列,等待调度器调度wake_up_new_task(p);...}创建完成后,调用wake_up_new_task将新创建的任务加入就绪队列,等待调度器调度执行。这段代码很长,我对其进行了一些压缩。//file:kernel/fork.cstaticstructtask_struct*copy_process(...){//4.1拷贝进程task_struct结构structtask_struct*p;p=dup_task_struct(当前);...//4.2复制files_structretval=copy_files(clone_flags,p);//4.3拷贝fs_structretval=copy_fs(clone_flags,p);//4.4复制mm_structretval=copy_mm(clone_flags,p);//4.5copyprocessnamespacensproxyretval=copy_namespaces(clone_flags,p);//4.6申请pid&&设置进程号pid=alloc_pid(p->nsproxy->pid_ns);p->pid=pid_nr(pid);p->tgid=p->pid;如果(clone_flags&CLONE_THREAD)p->tgid=current->tgid;......}可见,copy_process首先复制了一个新的task_struct,然后调用copy_xxx系列函数复制task_struct中的各种核心对象,同时也申请了pid。接下来,让我们在小节中查看函数的每个细节。4.1复制task_struct结构注意上面调用dup_task_struct时传入的参数是current,代表当前任务。在dup_task_struct中,会应用一个新的task_struct内核对象,并将当前任务复制到它里面。需要注意的是,本次复制只会复制task_struct结构本身,不会复制其内部包含的mm_struct等成员。下面简单看一下具体的代码。//file:kernel/fork.cstaticstructtask_struct*dup_task_struct(structtask_struct*orig){//申请task_struct内核对象tsk=alloc_task_struct_node(node);//复制task_structerr=arch_dup_task_struct(tsk,orig);...}其中alloc_task_struct_node用于在slab内核内存管理区申请一块内存。//file:kernel/fork.cstaticstructkmem_cache*task_struct_cachep;staticinlinestructtask_struct*alloc_task_struct_node(intnode){returnkmem_cache_alloc_node(task_struct_cachep,GFP_KERNEL,node);}申请内存后调用arch_dup_task_struct进行内存拷贝。//file:kernel/fork.cintarch_dup_task_struct(structtask_struct*dst,structtask_struct*src){*dst=*src;return0;}4.2Copyopenfilelist我们先回忆一下前面的内容,创建一个线程调用clone系统,调用的时候传入一堆flags,其中一个是CLONE_FILES。如果传递了CLONE_FILES标志,则将重用当前进程的打开文件列表(files成员)。对于创建过程,如果不传入此标志,将创建一个新的文件成员。好了,我们继续看copy_files的具体实现。//file:kernel/fork.cstaticintcopy_files(unsignedlongclone_flags,structtask_struct*tsk){structfiles_struct*oldf,*newf;oldf=当前->文件;如果(clone_flags&CLONE_FILES){atomic_inc(&oldf->count);出去;}newf=dup_fd(oldf,&error);tsk->files=newf;...}从代码中可以看出,如果指定了CLONE_FILES(在创建线程时),那么在原来的files_struct中只是+1,就算完成了,指针也没有改变,依然是files_struct对象创建它的过程。这是进程和线程的区别之一。对于进程来说,每个进程都需要一个独立的files_struct。但是对于线程,它会在创建它的线程中重用files_struct。4.3复制文件目录信息回想一下,在创建线程时,传递的标志还包括CLONE_FS。如果指定了这个标志,则重用当前进程的文件目录-fs成员。对于创建过程,如果不传入这个flag,就会创建一个新的fs。好了,我们继续看copy_fs的实现。//file:kernel/fork.cstaticintcopy_fs(unsignedlongclone_flags,structtask_struct*tsk){structfs_struct*fs=current->fs;if(clone_flags&CLONE_FS){fs->users++;返回0;}tsk->fs=copy_fs_struct(fs);return0;}类似于copy_files函数。如果在copy_fs中指定了CLONE_FS(创建线程时),并不是真正申请一个独立的fs_struct。近几年才在原来的fs用户+1就搞定了。在创建进程时,由于没有传递这个标志,所以会进入copy_fs_struct函数申请一个新的fs_struct,进行赋值复制。4.4复制内存地址空间CLONE_VM标志在创建线程时使用,但在创建进程时不使用。接下来在copy_mm函数中,会根据是否有这个标志来决定是与当前线程共享地址空间mm_struct还是新建一个。//file:kernel/fork.cstaticintcopy_mm(unsignedlongclone_flags,structtask_struct*tsk){structmm_struct*mm,*oldmm;oldmm=当前->mm;如果(clone_flags&CLONE_VM){atomic_inc(&oldmm->mm_users);毫米=旧毫米;转到good_mm;}mm=dup_mm(tsk);good_mm:返回0;}对于线程来说,由于传入了CLONE_VM标志,所以不会去申请一个新的mm_struct,而是共享它的父进程。多线程程序中的所有线程共享其父进程的地址空间。对于多进程程序,每个进程都有一个独立的mm_struct(地址空间)。因为线程和进程在内核中都是用task_struct表示的,线程和进程的区别在于它们与创建它们的父进程共享打开的文件列表、目录信息、虚拟地址空间等数据结构,比较轻量级。所以Linux下的线程也被称为轻量级进程。在打开的文件列表、目录信息和内存虚拟地址空间中,内存虚拟地址空间是最重要的。所以,区分一个Task任务应该叫线程还是进程,一般习惯看它是否有独立的地址空间。如果有,则称为进程,如果没有,则称为线程。这里再说一句,对于内核任务来说,不管有多少个任务,使用的地址空间都是一样的。所以一般称为内核线程,而不是内核进程。五、结束语我们已经介绍了创建线程的整个过程。综上所述,对于一个线程来说,它的地址空间mm_struct、目录信息fs_struct、打开文件列表files_struct都是和创建它的任务共享的。但是对于进程来说,地址空间mm_struct、挂载点fs_struct、打开文件列表files_struct都是独立拥有的,都需要申请内存并初始化。总之,Linux内核中并没有对线程进行特殊处理,仍然由task_struct管理。从内核的角度来看,用户态的线程本质上就是一个进程。只是相对于普通流程来说,有点“轻量级”。那么线程可以轻量化到什么程度呢?之前做过进程和线程上下文切换的开销测试。该过程的测试结果是上下文切换平均在2.7-5.48us之间。线程上下文切换时间约为3.8us。总的来说,进程线程切换也差不了多少。