大家好,我是飛哥!
關(guān)于進(jìn)程和線程,在 Linux 中是一對(duì)兒很核心的概念。但是進(jìn)程和線程到底有啥聯(lián)系,又有啥區(qū)別,很多人還都沒(méi)有搞清楚。
在網(wǎng)上對(duì)進(jìn)程和線程的討論中,很多都是聚集在這二位有啥不同。但事實(shí)在 Linux 上,進(jìn)程和線程的相同點(diǎn)要遠(yuǎn)遠(yuǎn)大于不同點(diǎn)。在 Linux 下的線程甚至都被稱為了輕量級(jí)進(jìn)程。
我今天就給大家從 Linux 內(nèi)核實(shí)現(xiàn)的角度,給大家深度對(duì)比下進(jìn)程和線程。
一、線程的創(chuàng)建方法
在 Redis 6.0 以上的版本里,也開(kāi)始支持使用多線程來(lái)提供核心服務(wù),我們就以它為例。
在 Redis 主線程啟動(dòng)以后,會(huì)調(diào)用 initThreadedIO 來(lái)創(chuàng)建多個(gè) io 線程。
redis 源碼地址:https://github.com/redis/redis
創(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)記。
在上面的代碼中,傳入參數(shù)中的各個(gè) flag 標(biāo)記是非常關(guān)鍵的。這里我們先知道一下傳入了 CLONE_VM、CLONE_FS、CLONE_FILES 等標(biāo)記就行了,后面我們會(huì)講內(nèi)核中針對(duì)這些參數(shù)做的特殊處理。
接下來(lái)的 do_clone 最終會(huì)調(diào)用一段匯編程序,在匯編里進(jìn)入 clone 系統(tǒng)調(diào)用,之后會(huì)進(jìn)入內(nèi)核中進(jìn)行處理。
二、內(nèi)核中對(duì)線程的表示
在開(kāi)始介紹線程的創(chuàng)建過(guò)程之前,先給大家看看內(nèi)核中表示線程的數(shù)據(jù)結(jié)構(gòu)。
開(kāi)篇的時(shí)候我說(shuō)了,進(jìn)程和線程的相同點(diǎn)要遠(yuǎn)遠(yuǎn)大于不同點(diǎn)。主要依據(jù)就是在 Linux 中,無(wú)論進(jìn)程還是線程,都是抽象成了 task 任務(wù),在源碼里都是用 task_struct 結(jié)構(gòu)來(lái)實(shí)現(xiàn)的。
我們來(lái)看 task_struct 具體的定義,它位于 include/linux/sched.h
這個(gè)數(shù)據(jù)結(jié)構(gòu)已經(jīng)在上一篇文章《Linux進(jìn)程是如何創(chuàng)建出來(lái)的?》中,我們?cè)敿?xì)介紹過(guò)了。
對(duì)于線程來(lái)講,所有的字段都是和進(jìn)程一樣的(本來(lái)就是一個(gè)結(jié)構(gòu)體來(lái)表示的)。包括狀態(tài)、pid、task 樹(shù)關(guān)系、地址空間、文件系統(tǒng)信息、打開(kāi)的文件信息等等字段,線程也都有。
這也就是我前面說(shuō)的,進(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)程。至于說(shuō)輕量在哪兒,稍后我們?cè)僬f(shuō)。
這里我們稍微說(shuō)一下 pid 和 tgid 這兩個(gè)字段。在 Linux 中,每一個(gè) task_struct 都需要被唯一的標(biāo)識(shí),它的 pid 就是唯一標(biāo)識(shí)號(hào)。
對(duì)于進(jìn)程來(lái)說(shuō),這個(gè) pid 就是我們平時(shí)常說(shuō)的進(jìn)程 pid。
對(duì)于線程來(lái)說(shuō),我們假如一個(gè)進(jìn)程下創(chuàng)建了多個(gè)線程出來(lái)。那么每個(gè)線程的 pid 都是不同的。但是我們一般又需要記錄線程是屬于哪個(gè)進(jìn)程的。這時(shí)候,tgid 就派上用場(chǎng)了,通過(guò) tgid 字段來(lái)表示自己所歸屬的進(jìn)程 ID。
這樣內(nèi)核通過(guò) tgid 可以知道線程屬于哪個(gè)進(jìn)程。
三、線程創(chuàng)建過(guò)程
要想知道進(jìn)程和線程的區(qū)別到底在哪兒,我們從線程的創(chuàng)建過(guò)程來(lái)詳細(xì)看一下。
3.1 回顧進(jìn)程創(chuàng)建
在《Linux進(jìn)程是如何創(chuàng)建出來(lái)的?》一文中我們了解了進(jìn)程的創(chuàng)建過(guò)程。事實(shí)上,進(jìn)程線程創(chuàng)建的時(shí)候,使用的函數(shù)看起來(lái)不一樣。但實(shí)際在底層實(shí)現(xiàn)上,最終都是使用同一個(gè)函數(shù)來(lái)實(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。
do_fork 函數(shù)又調(diào)用 copy_process 完成進(jìn)程的創(chuàng)建。
3.2 線程的創(chuàng)建
我們?cè)诒疚牡谝恍」?jié)里介紹到 lib 庫(kù)函數(shù) pthread_create 會(huì)調(diào)用到 clone 系統(tǒng)調(diào)用,為其傳入了一組 flag。
好,我們找到 clone 系統(tǒng)調(diào)用的實(shí)現(xiàn)。
同樣,do_fork 函數(shù)還是會(huì)執(zhí)行到 copy_process 來(lái)完成實(shí)際的創(chuàng)建。
3.3 進(jìn)程線程創(chuàng)建異同
可見(jiàn)和創(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 來(lái)完整創(chuàng)建。
不過(guò)創(chuàng)建過(guò)程的區(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)生啥影響,我們接著看接下來(lái)的內(nèi)容。
四、揭秘 do_fork 系統(tǒng)調(diào)用
在本節(jié)中我們以動(dòng)態(tài)的視角來(lái)看一下線程的創(chuàng)建過(guò)程.
前面我們看到,進(jìn)程和線程創(chuàng)建都是調(diào)用內(nèi)核中的 do_fork 函數(shù)來(lái)執(zhí)行的。在 do_fork 的實(shí)現(xiàn)中,核心是一個(gè) copy_process 函數(shù),它以拷貝父進(jìn)程(線程)的方式來(lái)生成一個(gè)新的 task_struct 出來(lái)。
在創(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)。
可見(jiàn),copy_process 先是復(fù)制了一個(gè)新的 task_struct 出來(lái),然后調(diào)用 copy_xxx 系列的函數(shù)對(duì) task_struct 中的各種核心對(duì)象進(jìn)行拷貝處理,還申請(qǐng)了 pid 。接下來(lái)我們分小節(jié)來(lái)查看該函數(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ù)制。
我們來(lái)簡(jiǎn)單看下具體的代碼。
其中 alloc_task_struct_node 用于在 slab 內(nèi)核內(nèi)存管理區(qū)中申請(qǐng)一塊內(nèi)存出來(lái)。關(guān)于 slab 機(jī)制請(qǐng)參考- 內(nèi)核內(nèi)存管理
申請(qǐng)完內(nèi)存后,調(diào)用 arch_dup_task_struct 進(jìn)行內(nèi)存拷貝。
4.2 拷貝打開(kāi)文件列表
我們先回憶一下前面的內(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)程的打開(kāi)文件列表 - files 成員。
好了,我們繼續(xù)看 copy_files 具體實(shí)現(xiàn)。
從代碼看出,如果指定了 CLONE_FILES(創(chuàng)建線程的時(shí)候),只是在原有的 files_struct 里面 +1 就算是完事了,指針不變,仍然是復(fù)用創(chuàng)建它的進(jìn)程的 files_struct 對(duì)象。
這就是進(jìn)程和線程的其中一個(gè)區(qū)別,對(duì)于進(jìn)程來(lái)講,每一個(gè)進(jìn)程都需要獨(dú)立的 files_struct。但是對(duì)于線程來(lái)講,它是和創(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)程來(lái)講,沒(méi)有傳入這個(gè)標(biāo)志,就會(huì)新創(chuàng)建一個(gè) fs 出來(lái)。
?
好,我們繼續(xù)看 copy_fs 的實(shí)現(xiàn)。
和 copy_files 函數(shù)類似,在 copy_fs 中如果指定了 CLONE_FS(創(chuàng)建線程的時(shí)候),并沒(méi)有真正申請(qǐng)獨(dú)立的 fs_struct 出來(lái),近幾年只是在原有的 fs 里的 users +1 就算是完事。
而在創(chuàng)建進(jìn)程的時(shí)候,由于沒(méi)有傳遞這個(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í)候沒(méi)帶。接下來(lái)在 copy_mm 函數(shù) 中會(huì)根據(jù)是否有這個(gè)標(biāo)志來(lái)決定是該和當(dāng)前線程共享一份地址空間 mm_struct,還是創(chuàng)建一份新的。
對(duì)于線程來(lái)講,由于傳入了 CLONE_VM 標(biāo)記,所以不會(huì)申請(qǐng)新的 mm_struct 出來(lái),而是共享其父進(jìn)程的。
多線程程序中的所有線程都會(huì)共享其父進(jìn)程的地址空間。
?
而對(duì)于多進(jìn)程程序來(lái)說(shuō),每一個(gè)進(jìn)程都有獨(dú)立的 mm_struct(地址空間)。
?
因?yàn)樵趦?nèi)核中線程和進(jìn)程都是用 task_struct 來(lái)表示,只不過(guò)線程和進(jìn)程的區(qū)別是會(huì)和創(chuàng)建它的父進(jìn)程共享打開(kāi)文件列表、目錄信息、虛擬地址空間等數(shù)據(jù)結(jié)構(gòu),會(huì)更輕量一些。所以在 Linux 下的線程也叫輕量級(jí)進(jìn)程。
在打開(kāi)文件列表、目錄信息、內(nèi)存虛擬地址空間中,內(nèi)存虛擬地址空間是最重要的。因此區(qū)分一個(gè) Task 任務(wù)該叫線程還是該叫進(jìn)程,一般習(xí)慣上就看它是否有獨(dú)立的地址空間。如果有,就叫做進(jìn)程,沒(méi)有,就叫做線程。
這里展開(kāi)多說(shuō)一句,對(duì)于內(nèi)核任務(wù)來(lái)說(shuō),無(wú)論有多少個(gè)任務(wù),其使用地址空間都是同一個(gè)。所以一般都叫內(nèi)核線程,而不是內(nèi)核進(jìn)程。
五 結(jié)論
創(chuàng)建線程的整個(gè)過(guò)程我們就介紹完了。回頭總結(jié)一下,對(duì)于線程來(lái)講,其地址空間 mm_struct、目錄信息 fs_struct、打開(kāi)文件列表 files_struct 都是和創(chuàng)建它的任務(wù)共享的。
但是對(duì)于進(jìn)程來(lái)講,地址空間 mm_struct、掛載點(diǎn) fs_struct、打開(kāi)文件列表 files_struct 都要是獨(dú)立擁有的,都需要去申請(qǐng)內(nèi)存并初始化它們。
?
總之,在 Linux 內(nèi)核中并沒(méi)有對(duì)線程做特殊處理,還是由 task_struct 來(lái)管理。從內(nèi)核的角度看,用戶態(tài)的線程本質(zhì)上還是一個(gè)進(jìn)程。只不過(guò)和普通進(jìn)程比,稍微“輕量”了那么一些。
那么線程具體能輕量多少呢?我之前曾經(jīng)做過(guò)一個(gè)進(jìn)程和線程的上下文切換開(kāi)銷測(cè)試。進(jìn)程的測(cè)試結(jié)果是一次上下文切換平均 2.7 - 5.48 us 之間。線程上下文切換是 3.8 us左右。總的來(lái)說(shuō),進(jìn)程線程切換還是沒(méi)差太多。
評(píng)論