關(guān)于進(jìn)程和線程,在linux中是非常核心的概念。然而,很多人對(duì)它們之間的聯(lián)系和區(qū)別并不清楚。
在網(wǎng)上對(duì)進(jìn)程和線程的討論中,大多數(shù)集中在它們之間的差異。但實(shí)際上,在Linux系統(tǒng)中,進(jìn)程和線程的相似之處要遠(yuǎn)遠(yuǎn)多于它們的不同之處。在Linux環(huán)境下,線程甚至被稱為輕量級(jí)進(jìn)程。
今天,我將從Linux內(nèi)核實(shí)現(xiàn)的角度,深入比較進(jìn)程和線程。
一、線程的創(chuàng)建方式
以redis 6.0以上的版本為例,它開始支持使用多線程提供核心服務(wù)。
一旦Redis主線程啟動(dòng),就會(huì)調(diào)用initThreadedIO函數(shù)來創(chuàng)建多個(gè)I/O線程。
?
//file:src/networking.c void?initThreadedIO(void)?{ ?//開始?io?線程的創(chuàng)建 ?for?(int?i?=?0;?i?
創(chuàng)建線程具體調(diào)用的是 pthread_create 函數(shù),pthread_create 是在 glibc 庫(kù)中實(shí)現(xiàn)的。在 glibc 庫(kù)中,pthread_create 函數(shù)的實(shí)現(xiàn)調(diào)用路徑是 __pthread_create_2_1 -> create_thread。其中 create_thread 這個(gè)函數(shù)比較重要,它設(shè)置了創(chuàng)建線程時(shí)使用的各種 flag 標(biāo)記。
//file:nptl/sysdeps/pthread/createthread.c static?int create_thread?(struct?pthread?*pd,?...) { ?int?clone_flags?=?(CLONE_VM?|?CLONE_FS?|?CLONE_FILES?|?CLONE_SIGNAL ????|?CLONE_SETTLS?|?CLONE_PARENT_SETTID ????|?CLONE_CHILD_CLEARTID?|?CLONE_SYSVSEM ????|?0); ?int?res?=?do_clone?(pd,?attr,?clone_flags,?start_thread, ??????STACK_VARIABLES_ARGS,?1); ?... }
在上面的代碼中,傳入?yún)?shù)中的各個(gè) flag 標(biāo)記是非常關(guān)鍵的。這里我們先知道一下傳入了 CLONE_VM、CLONE_FS、CLONE_FILES 等標(biāo)記就行了,后面我們會(huì)講內(nèi)核中針對(duì)這些參數(shù)做的特殊處理。
接下來的 do_clone 最終會(huì)調(diào)用一段匯編程序,在匯編里進(jìn)入 clone 系統(tǒng)調(diào)用,之后會(huì)進(jìn)入內(nèi)核中進(jìn)行處理。
//file:sysdeps/unix/sysv/linux/i386/clone.S ENTRY?(BP_SYM?(__clone)) ?... ?movl?$SYS_ify(clone),%eax ?...
二、內(nèi)核中對(duì)線程的表示
在開始介紹線程的創(chuàng)建過程之前,先給大家看看內(nèi)核中表示線程的數(shù)據(jù)結(jié)構(gòu)。
開篇的時(shí)候我說了,進(jìn)程和線程的相同點(diǎn)要遠(yuǎn)遠(yuǎn)大于不同點(diǎn)。主要依據(jù)就是在 Linux 中,無(wú)論進(jìn)程還是線程,都是抽象成了 task 任務(wù),在源碼里都是用 task_struct 結(jié)構(gòu)來實(shí)現(xiàn)的。
我們來看 task_struct 具體的定義,它位于 include/linux/sched.h
//file:include/linux/sched.h struct?task_struct?{ ?//1.1?task狀態(tài)? ?volatile?long?state; ?//1.2?進(jìn)程線程的pid ?pid_t?pid; ?pid_t?tgid; ?//1.3?task樹關(guān)系:父進(jìn)程、子進(jìn)程、兄弟進(jìn)程 ?struct?task_struct?__rcu?*parent; ?struct?list_head?children;? ?struct?list_head?sibling; ?struct?task_struct?*group_leader;? ?//1.4?task調(diào)度優(yōu)先級(jí) ?int?prio,?static_prio,?normal_prio; ?unsigned?int?rt_priority; ?//1.5?地址空間 ?struct?mm_struct?*mm,?*active_mm; ?//1.6?文件系統(tǒng)信息(當(dāng)前目錄等) ?struct?fs_struct?*fs; ?//1.7?打開的文件信息 ?struct?files_struct?*files; ?//1.8?namespaces? ?struct?nsproxy?*nsproxy; ?... }
這個(gè)數(shù)據(jù)結(jié)構(gòu)已經(jīng)在上一篇文章《Linux進(jìn)程是如何創(chuàng)建出來的?》中,我們?cè)敿?xì)介紹過了。
對(duì)于線程來講,所有的字段都是和進(jìn)程一樣的(本來就是一個(gè)結(jié)構(gòu)體來表示的)。包括狀態(tài)、pid、task 樹關(guān)系、地址空間、文件系統(tǒng)信息、打開的文件信息等等字段,線程也都有。
這也就是我前面說的,進(jìn)程和線程的相同點(diǎn)要遠(yuǎn)遠(yuǎn)大于不同點(diǎn),本質(zhì)上是同一個(gè)東西,都是一個(gè) task_struct !正因?yàn)檫M(jìn)程線程如此之相像,所以在 Linux 下的線程還有另外一個(gè)名字,叫輕量級(jí)進(jìn)程。至于說輕量在哪兒,稍后我們?cè)僬f。
這里我們稍微說一下 pid 和 tgid 這兩個(gè)字段。在 Linux 中,每一個(gè) task_struct 都需要被唯一的標(biāo)識(shí),它的 pid 就是唯一標(biāo)識(shí)號(hào)。
//file:include/linux/sched.h struct?task_struct?{ ?...... ?pid_t?pid; ?pid_t?tgid; }
對(duì)于進(jìn)程來說,這個(gè) pid 就是我們平時(shí)常說的進(jìn)程 pid。
對(duì)于線程來說,我們假如一個(gè)進(jìn)程下創(chuàng)建了多個(gè)線程出來。那么每個(gè)線程的 pid 都是不同的。但是我們一般又需要記錄線程是屬于哪個(gè)進(jìn)程的。這時(shí)候,tgid 就派上用場(chǎng)了,通過 tgid 字段來表示自己所歸屬的進(jìn)程 ID。
這樣內(nèi)核通過 tgid 可以知道線程屬于哪個(gè)進(jìn)程。
三、線程創(chuàng)建過程
要想知道進(jìn)程和線程的區(qū)別到底在哪兒,我們從線程的創(chuàng)建過程來詳細(xì)看一下。
3.1 回顧進(jìn)程創(chuàng)建
在《Linux進(jìn)程是如何創(chuàng)建出來的?》一文中我們了解了進(jìn)程的創(chuàng)建過程。事實(shí)上,進(jìn)程線程創(chuàng)建的時(shí)候,使用的函數(shù)看起來不一樣。但實(shí)際在底層實(shí)現(xiàn)上,最終都是使用同一個(gè)函數(shù)來實(shí)現(xiàn)的。
我們?cè)俸?jiǎn)單回顧一下創(chuàng)建進(jìn)程時(shí) fork 系統(tǒng)調(diào)用的源碼,fork 調(diào)用主要就是執(zhí)行了 do_fork 函數(shù)。注意:fork 函數(shù)調(diào)用 do_fork 的傳的參數(shù)分別是SIGCHLD、0,0,NULL,NULL。
//file:kernel/fork.c SYSCALL_DEFINE0(fork) { ?return?do_fork(SIGCHLD,?0,?0,?NULL,?NULL); }
do_fork 函數(shù)又調(diào)用 copy_process 完成進(jìn)程的創(chuàng)建。
//file:kernel/fork.c long?do_fork(...) { ?//復(fù)制一個(gè)?task_struct?出來 ?struct?task_struct?*p; ?p?=?copy_process(clone_flags,?...); ?... }
3.2 線程的創(chuàng)建
我們?cè)诒疚牡谝恍」?jié)里介紹到 lib 庫(kù)函數(shù) pthread_create 會(huì)調(diào)用到 clone 系統(tǒng)調(diào)用,為其傳入了一組 flag。
//file:nptl/sysdeps/pthread/createthread.c static?int create_thread?(struct?pthread?*pd,?...) { ?int?clone_flags?=?(CLONE_VM?|?CLONE_FS?|?CLONE_FILES?|?CLONE_SIGNAL ????|?CLONE_SETTLS?|?CLONE_PARENT_SETTID ????|?CLONE_CHILD_CLEARTID?|?CLONE_SYSVSEM ????|?0); ?int?res?=?do_clone?(pd,?attr,?clone_flags,?...); ?... }
好,我們找到 clone 系統(tǒng)調(diào)用的實(shí)現(xiàn)。
//file:kernel/fork.c SYSCALL_DEFINE5(clone,?......) { ?return?do_fork(clone_flags,?newsp,?0,?parent_tidptr,?child_tidptr); }
同樣,do_fork 函數(shù)還是會(huì)執(zhí)行到 copy_process 來完成實(shí)際的創(chuàng)建。
3.3 進(jìn)程線程創(chuàng)建異同
可見和創(chuàng)建進(jìn)程時(shí)使用的 fork 系統(tǒng)調(diào)用相比,創(chuàng)建線程的 clone 系統(tǒng)調(diào)用幾乎和 fork 差不多,也一樣使用的是內(nèi)核里的 do_fork 函數(shù),最后走到 copy_process 來完整創(chuàng)建。
不過創(chuàng)建過程的區(qū)別是二者在調(diào)用 do_fork 時(shí)傳入的 clone_flags 里的標(biāo)記不一樣!。
- 創(chuàng)建進(jìn)程時(shí)的 flag:僅有一個(gè) SIGCHLD
- 創(chuàng)建線程時(shí)的 flag:包括 CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_SIGNAL、CLONE_SETTLS、CLONE_PARENT_SETTID、CLONE_CHILD_CLEARTID、CLONE_SYSVSEM。
關(guān)于這些 flag 的含義,我們選幾個(gè)關(guān)鍵的做一個(gè)簡(jiǎn)單的介紹,后面介紹 do_fork 細(xì)節(jié)的時(shí)候會(huì)再次涉及到。
- CLONE_VM: 新 task 和父進(jìn)程共享地址空間
- CLONE_FS:新 task 和父進(jìn)程共享文件系統(tǒng)信息
- CLONE_FILES:新 task 和父進(jìn)程共享文件描述符表
這些 flag 會(huì)對(duì) task_struct 產(chǎn)生啥影響,我們接著看接下來的內(nèi)容。
四、揭秘 do_fork 系統(tǒng)調(diào)用
在本節(jié)中我們以動(dòng)態(tài)的視角來看一下線程的創(chuàng)建過程.
前面我們看到,進(jìn)程和線程創(chuàng)建都是調(diào)用內(nèi)核中的 do_fork 函數(shù)來執(zhí)行的。在 do_fork 的實(shí)現(xiàn)中,核心是一個(gè) copy_process 函數(shù),它以拷貝父進(jìn)程(線程)的方式來生成一個(gè)新的 task_struct 出來。
//file:kernel/fork.c long?do_fork(unsigned?long?clone_flags,?...) { ?//復(fù)制一個(gè)?task_struct?出來 ?struct?task_struct?*p; ?p?=?copy_process(clone_flags,?stack_start,?stack_size, ????child_tidptr,?NULL,?trace); ?//子任務(wù)加入到就緒隊(duì)列中去,等待調(diào)度器調(diào)度 ?wake_up_new_task(p); ?... }
在創(chuàng)建完畢后,調(diào)用 wake_up_new_task 將新創(chuàng)建的任務(wù)添加到就緒隊(duì)列中,等待調(diào)度器調(diào)度執(zhí)行。這個(gè)代碼很長(zhǎng),我對(duì)其進(jìn)行了一定程度的精簡(jiǎn)。
//file:kernel/fork.c static?struct?task_struct?*copy_process(...) { ?//4.1?復(fù)制進(jìn)程?task_struct?結(jié)構(gòu)體 ?struct?task_struct?*p; ?p?=?dup_task_struct(current); ?... ?//4.2?拷貝?files_struct ?retval?=?copy_files(clone_flags,?p); ?//4.3?拷貝?fs_struct ?retval?=?copy_fs(clone_flags,?p); ?//4.4?拷貝?mm_struct ?retval?=?copy_mm(clone_flags,?p); ?//4.5?拷貝進(jìn)程的命名空間?nsproxy ?retval?=?copy_namespaces(clone_flags,?p); ?//4.6?申請(qǐng)?pid?&&?設(shè)置進(jìn)程號(hào) ?pid?=?alloc_pid(p->nsproxy->pid_ns); ?p->pid?=?pid_nr(pid); ?p->tgid?=?p->pid; ?if?(clone_flags?&?CLONE_THREAD) ??p->tgid?=?current->tgid; ?...... }
可見,copy_process 先是復(fù)制了一個(gè)新的 task_struct 出來,然后調(diào)用 copy_xxx 系列的函數(shù)對(duì) task_struct 中的各種核心對(duì)象進(jìn)行拷貝處理,還申請(qǐng)了 pid 。接下來我們分小節(jié)來查看該函數(shù)的每一個(gè)細(xì)節(jié)。
4.1 復(fù)制 task_struct 結(jié)構(gòu)體
注意一下,上面調(diào)用 dup_task_struct 時(shí)傳入的參數(shù)是 current,它表示的是當(dāng)前任務(wù)。在 dup_task_struct 里,會(huì)申請(qǐng)一個(gè)新的 task_struct 內(nèi)核對(duì)象,然后將當(dāng)前任務(wù)復(fù)制給它。需要注意的是,這次拷貝只會(huì)拷貝 task_struct 結(jié)構(gòu)體本身,它內(nèi)部包含的 mm_struct 等成員不會(huì)被復(fù)制。
我們來簡(jiǎn)單看下具體的代碼。
//file:kernel/fork.c static?struct?task_struct?*dup_task_struct(struct?task_struct?*orig) { ?//申請(qǐng)?task_struct?內(nèi)核對(duì)象 ?tsk?=?alloc_task_struct_node(node); ?//復(fù)制?task_struct ?err?=?arch_dup_task_struct(tsk,?orig); ?... }
其中 alloc_task_struct_node 用于在 slab 內(nèi)核內(nèi)存管理區(qū)中申請(qǐng)一塊內(nèi)存出來。關(guān)于 slab 機(jī)制請(qǐng)參考- 內(nèi)核內(nèi)存管理
//file:kernel/fork.c static?struct?kmem_cache?*task_struct_cachep; static?inline?struct?task_struct?*alloc_task_struct_node(int?node) { ?return?kmem_cache_alloc_node(task_struct_cachep,?GFP_KERNEL,?node); }
申請(qǐng)完內(nèi)存后,調(diào)用 arch_dup_task_struct 進(jìn)行內(nèi)存拷貝。
//file:kernel/fork.c int?arch_dup_task_struct(struct?task_struct?*dst, ?????????struct?task_struct?*src) { ?*dst?=?*src; ?return?0; }
4.2 拷貝打開文件列表
我們先回憶一下前面的內(nèi)容,創(chuàng)建線程調(diào)用 clone 系統(tǒng)調(diào)用的時(shí)候,傳入了一堆的 flag,其中有一個(gè)就是 CLONE_FILES。如果傳入了 CLONE_FILES 標(biāo)記,就會(huì)復(fù)用當(dāng)前進(jìn)程的打開文件列表 – files 成員。
對(duì)于創(chuàng)建進(jìn)程來講,沒有傳入這個(gè)標(biāo)志,就會(huì)新創(chuàng)建一個(gè) files 成員出來。
好了,我們繼續(xù)看 copy_files 具體實(shí)現(xiàn)。
//file:kernel/fork.c static?int?copy_files(unsigned?long?clone_flags,?struct?task_struct?*tsk) { ?struct?files_struct?*oldf,?*newf; ?oldf?=?current->files; ?if?(clone_flags?&?CLONE_FILES)?{ ??atomic_inc(&oldf->count); ??goto?out; ?} ?newf?=?dup_fd(oldf,?&error); ?tsk->files?=?newf; ?... }
從代碼看出,如果指定了 CLONE_FILES(創(chuàng)建線程的時(shí)候),只是在原有的 files_struct 里面 +1 就算是完事了,指針不變,仍然是復(fù)用創(chuàng)建它的進(jìn)程的 files_struct 對(duì)象。
這就是進(jìn)程和線程的其中一個(gè)區(qū)別,對(duì)于進(jìn)程來講,每一個(gè)進(jìn)程都需要獨(dú)立的 files_struct。但是對(duì)于線程來講,它是和創(chuàng)建它的線程復(fù)用 files_struct 的。
4.3 拷貝文件目錄信息
再回憶一下創(chuàng)建線程的時(shí)候,傳入的 flag 里也包括 CLONE_FS。如果指定了這個(gè)標(biāo)志,就會(huì)復(fù)用當(dāng)前進(jìn)程的文件目錄 – fs 成員。
對(duì)于創(chuàng)建進(jìn)程來講,沒有傳入這個(gè)標(biāo)志,就會(huì)新創(chuàng)建一個(gè) fs 出來。
好,我們繼續(xù)看 copy_fs 的實(shí)現(xiàn)。
//file:kernel/fork.c static?int?copy_fs(unsigned?long?clone_flags,?struct?task_struct?*tsk) { ?struct?fs_struct?*fs?=?current->fs; ?if?(clone_flags?&?CLONE_FS)?{ ??fs->users++; ??return?0; ?} ?tsk->fs?=?copy_fs_struct(fs); ?return?0; }
和 copy_files 函數(shù)類似,在 copy_fs 中如果指定了 CLONE_FS(創(chuàng)建線程的時(shí)候),并沒有真正申請(qǐng)獨(dú)立的 fs_struct 出來,近幾年只是在原有的 fs 里的 users +1 就算是完事。
而在創(chuàng)建進(jìn)程的時(shí)候,由于沒有傳遞這個(gè)標(biāo)志,會(huì)進(jìn)入到 copy_fs_struct 函數(shù)中申請(qǐng)新的 fs_struct 并進(jìn)行賦值拷貝。
4.4 拷貝內(nèi)存地址空間
創(chuàng)建線程的時(shí)候帶了 CLONE_VM 標(biāo)志,而創(chuàng)建進(jìn)程的時(shí)候沒帶。接下來在 copy_mm 函數(shù) 中會(huì)根據(jù)是否有這個(gè)標(biāo)志來決定是該和當(dāng)前線程共享一份地址空間 mm_struct,還是創(chuàng)建一份新的。
//file:kernel/fork.c static?int?copy_mm(unsigned?long?clone_flags,?struct?task_struct?*tsk) { ?struct?mm_struct?*mm,?*oldmm; ?oldmm?=?current->mm; ?if?(clone_flags?&?CLONE_VM)?{ ??atomic_inc(&oldmm->mm_users); ??mm?=?oldmm; ??goto?good_mm; ?} ?mm?=?dup_mm(tsk); good_mm: ?return?0;? }
對(duì)于線程來講,由于傳入了 CLONE_VM 標(biāo)記,所以不會(huì)申請(qǐng)新的 mm_struct 出來,而是共享其父進(jìn)程的。
多線程程序中的所有線程都會(huì)共享其父進(jìn)程的地址空間。
而對(duì)于多進(jìn)程程序來說,每一個(gè)進(jìn)程都有獨(dú)立的 mm_struct(地址空間)。
因?yàn)樵趦?nèi)核中線程和進(jìn)程都是用 task_struct 來表示,只不過線程和進(jìn)程的區(qū)別是會(huì)和創(chuàng)建它的父進(jìn)程共享打開文件列表、目錄信息、虛擬地址空間等數(shù)據(jù)結(jié)構(gòu),會(huì)更輕量一些。所以在 Linux 下的線程也叫輕量級(jí)進(jìn)程。
在打開文件列表、目錄信息、內(nèi)存虛擬地址空間中,內(nèi)存虛擬地址空間是最重要的。因此區(qū)分一個(gè) Task 任務(wù)該叫線程還是該叫進(jìn)程,一般習(xí)慣上就看它是否有獨(dú)立的地址空間。如果有,就叫做進(jìn)程,沒有,就叫做線程。
這里展開多說一句,對(duì)于內(nèi)核任務(wù)來說,無(wú)論有多少個(gè)任務(wù),其使用地址空間都是同一個(gè)。所以一般都叫內(nèi)核線程,而不是內(nèi)核進(jìn)程。
五 結(jié)論
創(chuàng)建線程的整個(gè)過程我們就介紹完了。回頭總結(jié)一下,對(duì)于線程來講,其地址空間 mm_struct、目錄信息 fs_struct、打開文件列表 files_struct 都是和創(chuàng)建它的任務(wù)共享的。
但是對(duì)于進(jìn)程來講,地址空間 mm_struct、掛載點(diǎn) fs_struct、打開文件列表 files_struct 都要是獨(dú)立擁有的,都需要去申請(qǐng)內(nèi)存并初始化它們。
總之,在 Linux 內(nèi)核中并沒有對(duì)線程做特殊處理,還是由 task_struct 來管理。從內(nèi)核的角度看,用戶態(tài)的線程本質(zhì)上還是一個(gè)進(jìn)程。只不過和普通進(jìn)程比,稍微“輕量”了那么一些。
那么線程具體能輕量多少呢?我之前曾經(jīng)做過一個(gè)進(jìn)程和線程的上下文切換開銷測(cè)試。進(jìn)程的測(cè)試結(jié)果是一次上下文切換平均 2.7 – 5.48 us 之間。線程上下文切換是 3.8 us左右。總的來說,進(jìn)程線程切換還是沒差太多。