摘要:進(jìn)程和線程究竟是什么東西傳統(tǒng)網(wǎng)絡(luò)服務(wù)模型是如何工作的協(xié)程和線程的關(guān)系和區(qū)別有哪些過(guò)程在什么時(shí)間發(fā)生在剛剛結(jié)束的上海站,來(lái)自七牛云存儲(chǔ)的高級(jí)工程師許智翔帶來(lái)了關(guān)于的分享中的進(jìn)程線程協(xié)程同步異步回調(diào)。使用紅黑樹(shù)管理就緒隊(duì)列。
進(jìn)程和線程究竟是什么東西?傳統(tǒng)網(wǎng)絡(luò)服務(wù)模型是如何工作的?協(xié)程和線程的關(guān)系和區(qū)別有哪些?IO過(guò)程在什么時(shí)間發(fā)生?
在剛剛結(jié)束的 PyCon2014 上海站,來(lái)自七牛云存儲(chǔ)的 Python 高級(jí)工程師許智翔帶來(lái)了關(guān)于 Python 的分享《Python中的進(jìn)程、線程、協(xié)程、同步、異步、回調(diào)》。
一、上下文切換技術(shù) 簡(jiǎn)述在進(jìn)一步之前,讓我們先回顧一下各種上下文切換技術(shù)。
不過(guò)首先說(shuō)明一點(diǎn)術(shù)語(yǔ)。當(dāng)我們說(shuō)“上下文”的時(shí)候,指的是程序在執(zhí)行中的一個(gè)狀態(tài)。通常我們會(huì)用調(diào)用棧來(lái)表示這個(gè)狀態(tài)——棧記載了每個(gè)調(diào)用層級(jí)執(zhí)行到哪里,還有執(zhí)行時(shí)的環(huán)境情況等所有有關(guān)的信息。
當(dāng)我們說(shuō)“上下文切換”的時(shí)候,表達(dá)的是一種從一個(gè)上下文切換到另一個(gè)上下文執(zhí)行的技術(shù)。而“調(diào)度”指的是決定哪個(gè)上下文可以獲得接下去的CPU時(shí)間的方法。
進(jìn)程進(jìn)程是一種古老而典型的上下文系統(tǒng),每個(gè)進(jìn)程有獨(dú)立的地址空間,資源句柄,他們互相之間不發(fā)生干擾。
每個(gè)進(jìn)程在內(nèi)核中會(huì)有一個(gè)數(shù)據(jù)結(jié)構(gòu)進(jìn)行描述,我們稱(chēng)其為進(jìn)程描述符。這些描述符包含了系統(tǒng)管理進(jìn)程所需的信息,并且放在一個(gè)叫做任務(wù)隊(duì)列的隊(duì)列里面。
很顯然,當(dāng)新建進(jìn)程時(shí),我們需要分配新的進(jìn)程描述符,并且分配新的地址空間(和父地址空間的映射保持一致,但是兩者同時(shí)進(jìn)入COW狀態(tài))。這些過(guò)程需要一定的開(kāi)銷(xiāo)。
進(jìn)程狀態(tài)忽略去linux內(nèi)核復(fù)雜的狀態(tài)轉(zhuǎn)移表,我們實(shí)際上可以把進(jìn)程狀態(tài)歸結(jié)為三個(gè)最主要的狀態(tài):就緒態(tài),運(yùn)行態(tài),睡眠態(tài)。這就是任何一本系統(tǒng)書(shū)上都有的三態(tài)轉(zhuǎn)換圖。
就緒和執(zhí)行可以互相轉(zhuǎn)換,基本這就是調(diào)度的過(guò)程。而當(dāng)執(zhí)行態(tài)程序需要等待某些條件(最典型就是IO)時(shí),就會(huì)陷入睡眠態(tài)。而條件達(dá)成后,一般會(huì)自動(dòng)進(jìn)入就緒。
阻塞當(dāng)進(jìn)程需要在某個(gè)文件句柄上做IO,這個(gè)fd又沒(méi)有數(shù)據(jù)給他的時(shí)候,就會(huì)發(fā)生阻塞。具體來(lái)說(shuō),就是記錄XX進(jìn)程阻塞在了XX fd上,然后將進(jìn)程標(biāo)記為睡眠態(tài),并調(diào)度出去。當(dāng)fd上有數(shù)據(jù)時(shí)(例如對(duì)端發(fā)送的數(shù)據(jù)到達(dá)),就會(huì)喚醒阻塞在fd上的進(jìn)程。進(jìn)程會(huì)隨后進(jìn)入就緒隊(duì)列,等待合適的時(shí)間被調(diào)度。
阻塞后的喚醒也是一個(gè)很有意思的話題。當(dāng)多個(gè)上下文阻塞在一個(gè)fd上(雖然不多見(jiàn),但是后面可以看到一個(gè)例子),而且fd就緒時(shí),應(yīng)該喚醒多少個(gè)上下文呢?傳統(tǒng)上應(yīng)當(dāng)喚醒所有上下文,因?yàn)槿绻麅H喚醒一個(gè),而這個(gè)上下文又不能消費(fèi)所有數(shù)據(jù)時(shí),就會(huì)使得其他上下文處于無(wú)謂的死鎖中。
但是有個(gè)著名的例子——accept,也是使用讀就緒來(lái)表示收到的。如果試圖用多個(gè)線程來(lái)accept會(huì)發(fā)生什么?當(dāng)有新連接時(shí),所有上下文都會(huì)就緒,但是只有第一個(gè)可以實(shí)際獲得fd,其他的被調(diào)度后又立刻阻塞。這就是驚群?jiǎn)栴}thundering herd problem。
現(xiàn)代linux內(nèi)核已經(jīng)解決了這個(gè)問(wèn)題,方法驚人的簡(jiǎn)單——accept方法加鎖。
(inet_connection_sock.c:inet_csk_wait_for_connect)線程
線程是一種輕量進(jìn)程,實(shí)際上在linux內(nèi)核中,兩者幾乎沒(méi)有差別,除了一點(diǎn)——線程并不產(chǎn)生新的地址空間和資源描述符表,而是復(fù)用父進(jìn)程的。
但是無(wú)論如何,線程的調(diào)度和進(jìn)程一樣,必須陷入內(nèi)核態(tài)。
為每個(gè)客戶分配一個(gè)進(jìn)程。優(yōu)點(diǎn)是業(yè)務(wù)隔離,在一個(gè)進(jìn)程中出現(xiàn)的錯(cuò)誤不至于影響整個(gè)系統(tǒng),甚至其他進(jìn)程。Oracle傳統(tǒng)上就是進(jìn)程模型。缺點(diǎn)是進(jìn)程的分配和釋放有非常高的成本。因此Oracle需要連接池來(lái)保持連接減少新建和釋放,同時(shí)盡量復(fù)用連接而不是隨意的新建連接。
線程模型為每客戶分配一個(gè)線程。優(yōu)點(diǎn)是更輕量,建立和釋放速度更快,而且多個(gè)上下文間的通訊速度非???。缺點(diǎn)是一個(gè)線程出現(xiàn)問(wèn)題容易將整個(gè)系統(tǒng)搞崩潰。
一個(gè)例子py_http_fork_thread.py
在這個(gè)例子中,線程模式和進(jìn)程模式可以輕易的互換。
如何工作的:
父進(jìn)程監(jiān)聽(tīng)服務(wù)端口
在有新連接建立的時(shí)候,父進(jìn)程執(zhí)行fork,產(chǎn)生一個(gè)子進(jìn)程副本
如果子進(jìn)程需要的話,可以exec(例如CGI)
父進(jìn)程執(zhí)行(理論上應(yīng)當(dāng)先執(zhí)行子進(jìn)程,因?yàn)閑xec執(zhí)行的快可以避免COW)到accept后,發(fā)生阻塞
上下文調(diào)度,內(nèi)核調(diào)度器選擇下一個(gè)上下文,如無(wú)意外,應(yīng)當(dāng)就是剛剛派生的子進(jìn)程
子進(jìn)程進(jìn)程進(jìn)入讀取處理狀態(tài),阻塞在read調(diào)用上,所有上下文均進(jìn)入睡眠態(tài)
隨著SYN或者數(shù)據(jù)報(bào)文到來(lái),CPU會(huì)喚醒對(duì)應(yīng)fd上阻塞的上下文(wait_queue),切換到就緒態(tài),并加入調(diào)度隊(duì)列
上下文繼續(xù)執(zhí)行到下一個(gè)阻塞調(diào)用,或者因?yàn)闀r(shí)間片耗盡被掛起
評(píng)價(jià)同步模型,編寫(xiě)自然,每個(gè)上下文可以當(dāng)作其他上下文不存在一樣的操作,每次讀取數(shù)據(jù)可以當(dāng)作必然能讀取到。
進(jìn)程模型自然的隔離了連接。即使程序復(fù)雜且易崩潰,也只影響一個(gè)連接而不是在整個(gè)系統(tǒng)。
生成和釋放開(kāi)銷(xiāo)很大(效率測(cè)試的進(jìn)程fork和線程模式開(kāi)銷(xiāo)測(cè)試),需要考慮復(fù)用。
進(jìn)程模式的多客戶通訊比較麻煩,尤其在共享大量數(shù)據(jù)的時(shí)候。
性能thread模式,虛擬機(jī):
1: 909.27 2: 3778.38 3: 4815.37 4: 5000.04 10: 4998.16 50: 4881.93 100: 4603.24 200: 3445.12 500: 1778.26 (出現(xiàn)錯(cuò)誤)
fork模式,虛擬機(jī):
1: 384.14 2: 435.67 3: 435.17 4: 437.54 10: 383.11 50: 364.03 100: 320.51 (出現(xiàn)錯(cuò)誤)
thread模式,物理機(jī):
1: 6942.78 2: 6891.23 3: 6584.38 4: 6517.23 10: 6178.50 50: 4926.91 100: 2377.77
注意在python中,雖然有GIL,但是一個(gè)線程陷入到網(wǎng)絡(luò)IO的時(shí)候,GIL是解鎖的。因此從調(diào)用開(kāi)始到調(diào)用結(jié)束,減去CPU切換到其他上下文的時(shí)間,是可以多線程的?,F(xiàn)象是,在此種狀況下可以觀測(cè)到短暫的python CPU用量超過(guò)100%。
如果執(zhí)行多個(gè)上下文,可以充分利用這段時(shí)間。所觀測(cè)到的結(jié)果就是,只能單核的python,在小范圍內(nèi),其隨著并發(fā)數(shù)上升,性能居然會(huì)跟著上升。如果將這個(gè)過(guò)程轉(zhuǎn)移到一臺(tái)物理機(jī)上執(zhí)行,那么基本不能得出這樣的結(jié)論。這主要是因?yàn)樘摂M機(jī)上內(nèi)核陷入的開(kāi)銷(xiāo)更高。
三、C10K 問(wèn)題 描述當(dāng)同時(shí)連接數(shù)在10K左右時(shí),傳統(tǒng)模型就不再適用。實(shí)際上在效率測(cè)試報(bào)告的線程切換開(kāi)銷(xiāo)一節(jié)可以看到,超過(guò)1K后性能就差的一塌糊涂了。
進(jìn)程模型的問(wèn)題:在C10K的時(shí)候,啟動(dòng)和關(guān)閉這么多進(jìn)程是不可接受的開(kāi)銷(xiāo)。事實(shí)上單純的進(jìn)程fork模型在C1K時(shí)就應(yīng)當(dāng)拋棄了。
Apache的prefork模型,是使用預(yù)先分配(pre)的進(jìn)程池。這些進(jìn)程是被復(fù)用的。但即便是復(fù)用,本文所描述的很多問(wèn)題仍不可避免。
線程模式的問(wèn)題從任何測(cè)試都可以表明,線程模式比進(jìn)程模式更耐久一些,性能更好。但是在面對(duì)C10K還是力不從心的。問(wèn)題是,線程模式的問(wèn)題出在哪里呢?
內(nèi)存?有些人可能認(rèn)為線程模型的失敗首先在于內(nèi)存。如果你這么認(rèn)為,一定是因?yàn)槟悴殚喠朔浅@系馁Y料,并且沒(méi)仔細(xì)思考過(guò)。
你可能看到資料說(shuō),一個(gè)線程棧會(huì)消耗8M內(nèi)存(linux默認(rèn)值,ulimit可以看到),512個(gè)線程棧就會(huì)消耗4G內(nèi)存,而10K個(gè)線程就是80G。所以首先要考慮調(diào)整棧深度,并考慮爆棧問(wèn)題。
聽(tīng)起來(lái)很有道理,問(wèn)題是——linux的棧是通過(guò)缺頁(yè)來(lái)分配內(nèi)存的(How does stack allocation work in Linux?),不是所有棧地址空間都分配了內(nèi)存。因此,8M是最大消耗,實(shí)際的內(nèi)存消耗只會(huì)略大于實(shí)際需要的內(nèi)存(內(nèi)部損耗,每個(gè)在4k以內(nèi))。但是內(nèi)存一旦被分配,就很難回收(除非線程結(jié)束),這是線程模式的缺陷。
這個(gè)問(wèn)題提出的前提是,32位下地址空間有限。雖然10K個(gè)線程不一定會(huì)耗盡內(nèi)存,但是512個(gè)線程一定會(huì)耗盡地址空間。然而這個(gè)問(wèn)題對(duì)于目前已經(jīng)成為主流的64位系統(tǒng)來(lái)說(shuō)根本不存在。
內(nèi)核陷入開(kāi)銷(xiāo)?所謂內(nèi)核陷入開(kāi)銷(xiāo),就是指CPU從非特權(quán)轉(zhuǎn)向特權(quán),并且做輸入檢查的一些開(kāi)銷(xiāo)。這些開(kāi)銷(xiāo)在不同的系統(tǒng)上差異很大。
線程模型主要通過(guò)陷入切換上下文,因此陷入開(kāi)銷(xiāo)大聽(tīng)起來(lái)有點(diǎn)道理。實(shí)際上,這也是不成立的。線程在什么時(shí)候發(fā)生陷入切換?正常情況下,應(yīng)當(dāng)是IO阻塞的時(shí)候。同樣的IO量,難道其他模型就不需要陷入了么?只是非阻塞模型有很大可能直接返回,并不發(fā)生上下文切換而已。
效率測(cè)試報(bào)告的基礎(chǔ)調(diào)用開(kāi)銷(xiāo)一節(jié),證實(shí)了當(dāng)代操作系統(tǒng)上內(nèi)核陷入開(kāi)銷(xiāo)是非常驚人的小的(10個(gè)時(shí)鐘周期這個(gè)量級(jí))。
線程模型的問(wèn)題在于切換成本高熟悉linux內(nèi)核的應(yīng)該知道,近代linux調(diào)度器經(jīng)過(guò)幾個(gè)階段的發(fā)展。
linux2.4的調(diào)度器
O(1)調(diào)度器
CFS
實(shí)際上直到O(1),調(diào)度器的調(diào)度復(fù)雜度才和隊(duì)列長(zhǎng)度無(wú)關(guān)。在此之前,過(guò)多的線程會(huì)使得開(kāi)銷(xiāo)隨著線程數(shù)增長(zhǎng)(不保證線性)。
O(1)調(diào)度器看起來(lái)似乎是完全不隨著線程的影響。但是這個(gè)調(diào)度器有顯著的缺點(diǎn)——難于理解和維護(hù),并且在一些情況下會(huì)導(dǎo)致交互式程序響應(yīng)緩慢。
CFS使用紅黑樹(shù)管理就緒隊(duì)列。每次調(diào)度,上下文狀態(tài)轉(zhuǎn)換,都會(huì)查詢或者變更紅黑樹(shù)。紅黑樹(shù)的開(kāi)銷(xiāo)大約是O(logm),其中m大約為活躍上下文數(shù)(準(zhǔn)確的說(shuō)是同優(yōu)先級(jí)上下文數(shù)),大約和活躍的客戶數(shù)相當(dāng)。
因此,每當(dāng)線程試圖讀寫(xiě)網(wǎng)絡(luò),并遇到阻塞時(shí),都會(huì)發(fā)生O(logm)級(jí)別的開(kāi)銷(xiāo)。而且每次收到報(bào)文,喚醒阻塞在fd上的上下文時(shí),同樣要付出O(logm)級(jí)別的開(kāi)銷(xiāo)。
分析O(logm)的開(kāi)銷(xiāo)看似并不大,但是卻是一個(gè)無(wú)法接受的開(kāi)銷(xiāo)。因?yàn)镮O阻塞是一個(gè)經(jīng)常發(fā)生的事情。每次IO阻塞,都會(huì)發(fā)生開(kāi)銷(xiāo)。而且決定活躍線程數(shù)的是用戶,這不是我們可控制的。更糟糕的是,當(dāng)性能下降,響應(yīng)速度下降時(shí)。同樣的用戶數(shù)下,活躍上下文會(huì)上升(因?yàn)轫憫?yīng)變慢了)。這會(huì)進(jìn)一步拉低性能。
問(wèn)題的關(guān)鍵在于,http服務(wù)并不需要對(duì)每個(gè)用戶完全公平,偶爾某個(gè)用戶的響應(yīng)時(shí)間大大的延長(zhǎng)了是可以接受的。在這種情況下,使用紅黑樹(shù)去組織待處理fd列表(其實(shí)是上下文列表),并且反復(fù)計(jì)算和調(diào)度,是無(wú)謂的開(kāi)銷(xiāo)。
四、多路復(fù)用 簡(jiǎn)述要突破C10K問(wèn)題,必須減少系統(tǒng)內(nèi)活躍上下文數(shù)(其實(shí)未必,例如換一個(gè)調(diào)度器,例如使用RT的SCHED_RR),因此就要求一個(gè)上下文同時(shí)處理多個(gè)鏈接。而要做到這點(diǎn),就必須在每次系統(tǒng)調(diào)用讀取或?qū)懭霐?shù)據(jù)時(shí)立刻返回。否則上下文持續(xù)阻塞在調(diào)用上,如何能夠復(fù)用?這要求fd處于非阻塞狀態(tài),或者數(shù)據(jù)就緒。
上文所說(shuō)的所有IO操作,其實(shí)都特指了他的阻塞版本。所謂阻塞,就是上下文在IO調(diào)用上等待直到有合適的數(shù)據(jù)為止。這種模式給人一種“只要讀取數(shù)據(jù)就必定能讀到”的感覺(jué)。而非阻塞調(diào)用,就是上下文立刻返回。如果有數(shù)據(jù),帶回?cái)?shù)據(jù)。如果沒(méi)有數(shù)據(jù),帶回錯(cuò)誤(EAGAIN)。因此,“雖然發(fā)生錯(cuò)誤,但是不代表出錯(cuò)”。
但是即使有了非阻塞模式,依然繞不過(guò)就緒通知問(wèn)題。如果沒(méi)有合適的就緒通知技術(shù),我們只能在多個(gè)fd中盲目的重試,直到碰巧讀到一個(gè)就緒的fd為止。這個(gè)效率之差可想而知。
在就緒通知技術(shù)上,有兩種大的模式——就緒事件通知和異步IO。其差別簡(jiǎn)要來(lái)說(shuō)有兩點(diǎn)。就緒通知維護(hù)一個(gè)狀態(tài),由用戶讀取。而異步IO由系統(tǒng)調(diào)用用戶的回調(diào)函數(shù)。就緒通知在數(shù)據(jù)就緒時(shí)就生效,而異步IO直到數(shù)據(jù)IO完成才發(fā)生回調(diào)。
linux下的主流方案一直是就緒通知,其內(nèi)核態(tài)異步IO方案甚至沒(méi)有被封裝到glibc里去。圍繞就緒通知,linux總共提出過(guò)三種解決方案。我們繞過(guò)select和poll方案,看看epoll方案的特性。
另外提一點(diǎn)。有趣的是,當(dāng)使用了epoll后(更準(zhǔn)確說(shuō)只有在LT模式下),fd是否為非阻塞其實(shí)已經(jīng)不重要了。因?yàn)閑poll保證每次去讀取的時(shí)候都能讀到數(shù)據(jù),因此不會(huì)阻塞在調(diào)用上。
epoll用戶可以新建一個(gè)epoll文件句柄,并且將其他fd和這個(gè)"epoll fd"關(guān)聯(lián)。此后可以通過(guò)epoll fd讀取到所有就緒的文件句柄。
epoll有兩大模式,ET和LT。LT模式下,每次讀取就緒句柄都會(huì)讀取出完整的就緒句柄。而ET模式下,只給出上次到這次調(diào)用間新就緒的句柄。換個(gè)說(shuō)法,如果ET模式下某次讀取出了一個(gè)句柄,這個(gè)句柄從未被讀取完過(guò)——也就是從沒(méi)有從就緒變?yōu)槲淳途w。那么這個(gè)句柄就永遠(yuǎn)不會(huì)被新的調(diào)用返回,哪怕上面其實(shí)充滿了數(shù)據(jù)——因?yàn)榫浔鸁o(wú)法經(jīng)歷從非就緒變?yōu)榫途w的過(guò)程。
類(lèi)似CFS,epoll也使用了紅黑樹(shù)——不過(guò)是用于組織加入epoll的所有fd。epoll的就緒列表使用的是雙向隊(duì)列。這方便系統(tǒng)將某個(gè)fd加入隊(duì)列中,或者從隊(duì)列中解除。
要進(jìn)一步了解epoll的具體實(shí)現(xiàn),可以參考這篇linux下poll和epoll內(nèi)核源碼剖析。
性能如果使用非阻塞函數(shù),就不存在阻塞IO導(dǎo)致上下文切換了,而是變?yōu)闀r(shí)間片耗盡被搶占(大部分情況下如此),因此讀寫(xiě)的額外開(kāi)銷(xiāo)被消除。而epoll的常規(guī)操作,都是O(1)量級(jí)的。而epoll wait的復(fù)制動(dòng)作,則和當(dāng)前需要返回的fd數(shù)有關(guān)(在LT模式下幾乎就等同于上面的m,而ET模式下則會(huì)大大減少)。
但是epoll存在一點(diǎn)細(xì)節(jié)問(wèn)題。epoll fd的管理使用紅黑樹(shù),因此在加入和刪除時(shí)需要O(logn)復(fù)雜度(n為總連接數(shù)),而且關(guān)聯(lián)操作還必須每個(gè)fd調(diào)用一次。因此在大連接量下頻繁建立和關(guān)閉連接仍然有一定性能問(wèn)題(超短連接)。不過(guò)關(guān)聯(lián)操作調(diào)用畢竟比較少。如果確實(shí)是超短連接,tcp連接和釋放開(kāi)銷(xiāo)就很難接受了,所以對(duì)總體性能影響不大。
固有缺陷原理上說(shuō),epoll實(shí)現(xiàn)了一個(gè)wait_queue的回調(diào)函數(shù),因此原理上可以監(jiān)聽(tīng)任何能夠激活wait_queue的對(duì)象。但是epoll的最大問(wèn)題是無(wú)法用于普通文件,因?yàn)槠胀ㄎ募冀K是就緒的——雖然在讀取的時(shí)候不是這樣。
這導(dǎo)致基于epoll的各種方案,一旦讀到普通文件上下文仍然會(huì)阻塞。golang為了解決這個(gè)問(wèn)題,在每次調(diào)用syscall的時(shí)候,會(huì)獨(dú)立的啟動(dòng)一個(gè)線程,在獨(dú)立的線程中進(jìn)行調(diào)用。因此golang在IO普通文件的時(shí)候網(wǎng)絡(luò)不會(huì)阻塞。
五、事件通知機(jī)制下的幾種程序設(shè)計(jì)模型 簡(jiǎn)述使用通知機(jī)制的一大缺憾就是,用戶進(jìn)行IO操作后會(huì)陷入茫然——IO沒(méi)有完成,所以當(dāng)前上下文不能繼續(xù)執(zhí)行。但是由于復(fù)用線程的要求,當(dāng)前線程還需要接著執(zhí)行。所以,在如何進(jìn)行異步編程上,又分化出數(shù)種方案。
用戶態(tài)調(diào)度首先需要知道的一點(diǎn)就是,異步編程大多數(shù)情況下都伴隨著用戶態(tài)調(diào)度問(wèn)題——即使不使用上下文技術(shù)。
因?yàn)橄到y(tǒng)不會(huì)自動(dòng)根據(jù)fd的阻塞狀況來(lái)喚醒合適的上下文了,所以這個(gè)工作必須由其他人——一般就是某種框架——來(lái)完成。
你可以想像一個(gè)fd映射到對(duì)象的大map表,當(dāng)我們從epoll中得知某個(gè)fd就緒后,需要喚醒某種對(duì)象,讓他處理fd對(duì)應(yīng)的數(shù)據(jù)。
當(dāng)然,實(shí)際情況會(huì)更加復(fù)雜一些。原則上所有不占用CPU時(shí)間的等待都需要中斷執(zhí)行,陷入睡眠,并且交由某種機(jī)構(gòu)管理,等待合適的機(jī)會(huì)被喚醒。例如sleep,或是文件IO,還有l(wèi)ock。更精確的說(shuō),所有在內(nèi)核里面涉及到wait_queue的,在框架里面都需要做這種機(jī)制——也就是把內(nèi)核的調(diào)度和等待搬到用戶態(tài)來(lái)。
當(dāng)然,其實(shí)也有反過(guò)來(lái)的方案——就是把程序扔到內(nèi)核里面去。其中最著名的實(shí)例大概是微軟的http服務(wù)器了。
這個(gè)所謂的“可喚醒可中斷對(duì)象”,用的最多的就是協(xié)程。
協(xié)程協(xié)程是一種編程組件,可以在不陷入內(nèi)核的情況進(jìn)行上下文切換。如此一來(lái),我們就可以把協(xié)程上下文對(duì)象關(guān)聯(lián)到fd,讓fd就緒后協(xié)程恢復(fù)執(zhí)行。
當(dāng)然,由于當(dāng)前地址空間和資源描述符的切換無(wú)論如何需要內(nèi)核完成,因此協(xié)程所能調(diào)度的,只有在同一進(jìn)程中的不同上下文而已。
這是如何做到的呢?
我們?cè)趦?nèi)核里實(shí)行上下文切換的時(shí)候,其實(shí)是將當(dāng)前所有寄存器保存到內(nèi)存中,然后從另一塊內(nèi)存中載入另一組已經(jīng)被保存的寄存器。對(duì)于圖靈機(jī)來(lái)說(shuō),當(dāng)前狀態(tài)寄存器意味著機(jī)器狀態(tài)——也就是整個(gè)上下文。其余內(nèi)容,包括棧上內(nèi)存,堆上對(duì)象,都是直接或者間接的通過(guò)寄存器來(lái)訪問(wèn)的。
但是請(qǐng)仔細(xì)想想,寄存器更換這種事情,似乎不需要進(jìn)入內(nèi)核態(tài)么。事實(shí)上我們?cè)谟脩魬B(tài)切換的時(shí)候,就是用了類(lèi)似方案。
C coroutine的實(shí)現(xiàn),基本大多是保存現(xiàn)場(chǎng)和恢復(fù)之類(lèi)的過(guò)程。python則是保存當(dāng)前thread的top frame(greenlet)。
但是非常悲劇的,純用戶態(tài)方案(setjmp/longjmp)在多數(shù)系統(tǒng)上執(zhí)行的效率很高,但是并不是為了協(xié)程而設(shè)計(jì)的。setjmp并沒(méi)有拷貝整個(gè)棧(大多數(shù)的coroutine方案也不應(yīng)該這么做),而是只保存了寄存器狀態(tài)。這導(dǎo)致新的寄存器狀態(tài)和老寄存器狀態(tài)共享了同一個(gè)棧,從而在執(zhí)行時(shí)互相破壞。而完整的coroutine方案應(yīng)當(dāng)在特定時(shí)刻新建一個(gè)棧。
而比較好的方案(makecontext/swapcontext)則需要進(jìn)入內(nèi)核(sigprocmask),這導(dǎo)致整個(gè)調(diào)用的性能非常低。
協(xié)程與線程的關(guān)系首先我們可以明確,協(xié)程不能調(diào)度其他進(jìn)程中的上下文。而后,每個(gè)協(xié)程要獲得CPU,都必須在線程中執(zhí)行。因此,協(xié)程所能利用的CPU數(shù)量,和用于處理協(xié)程的線程數(shù)量直接相關(guān)。
作為推論,在單個(gè)線程中執(zhí)行的協(xié)程,可以視為單線程應(yīng)用。這些協(xié)程,在未執(zhí)行到特定位置(基本就是阻塞操作)前,是不會(huì)被搶占,也不會(huì)和其他CPU上的上下文發(fā)生同步問(wèn)題的。因此,一段協(xié)程代碼,中間沒(méi)有可能導(dǎo)致阻塞的調(diào)用,執(zhí)行在單個(gè)線程中。那么這段內(nèi)容可以被視為同步的。
我們經(jīng)常可以看到某些協(xié)程應(yīng)用,一啟動(dòng)就是數(shù)個(gè)進(jìn)程。這并不是跨進(jìn)程調(diào)度協(xié)程。一般來(lái)說(shuō),這是將一大群fd分給多個(gè)進(jìn)程,每個(gè)進(jìn)程自己再做fd-協(xié)程對(duì)應(yīng)調(diào)度。
基于就緒通知的協(xié)程框架
首先需要包裝read/write,在發(fā)生read的時(shí)候檢查返回。如果是EAGAIN,那么將當(dāng)前協(xié)程標(biāo)記為阻塞在對(duì)應(yīng)fd上,然后執(zhí)行調(diào)度函數(shù)。
調(diào)度函數(shù)需要執(zhí)行epoll(或者從上次的返回結(jié)果緩存中取數(shù)據(jù),減少內(nèi)核陷入次數(shù)),從中讀取一個(gè)就緒的fd。如果沒(méi)有,上下文應(yīng)當(dāng)被阻塞到至少有一個(gè)fd就緒。
查找這個(gè)fd對(duì)應(yīng)的協(xié)程上下文對(duì)象,并調(diào)度過(guò)去。
當(dāng)某個(gè)協(xié)程被調(diào)度到時(shí),他多半應(yīng)當(dāng)在調(diào)度器返回的路上——也就是read/write讀不到數(shù)據(jù)的時(shí)候。因此應(yīng)當(dāng)再重試讀取,失敗的話返回1。
如果讀取到數(shù)據(jù)了,直接返回。
這樣,異步的數(shù)據(jù)讀寫(xiě)動(dòng)作,在我們的想像中就可以變?yōu)橥降?。而我們知道同步模型?huì)極大降低我們的編程負(fù)擔(dān)。
CPS模型其實(shí)這個(gè)模型有個(gè)更流行的名字——回調(diào)模型。之所以扯上CPS這么高大上的玩意,主要是里面涉及不少有趣的話題。
首先是回調(diào)模型的大致過(guò)程。在IO調(diào)用的時(shí)候,同時(shí)傳入一個(gè)函數(shù),作為返回函數(shù)。當(dāng)IO結(jié)束時(shí),調(diào)用傳入的函數(shù)來(lái)處理下面的流程。這個(gè)模型聽(tīng)起來(lái)挺簡(jiǎn)單的。
然后是CPS。用一句話來(lái)描述這個(gè)模型——他把一切操作都當(dāng)作了IO,無(wú)論干什么,結(jié)果要通過(guò)回調(diào)函數(shù)來(lái)返回。從這個(gè)角度來(lái)說(shuō),IO回調(diào)模型只能被視作CPS的一個(gè)特例。
例如,我們需要計(jì)算1+2*3,在cps里面就需要這么寫(xiě):
mul(lambda x: add(pprint.pprint, x, 1), 2, 3)
其中mul和add在python里面如下定義:
add = lambda f, *nums: f(sum(nums)) mul = lambda f, *nums: f(reduce(lambda x,y: x*y, nums))
而且由于python沒(méi)有TCO,所以這樣的寫(xiě)法會(huì)產(chǎn)生非常多的frame。
但是要正確理解這個(gè)模型,你需要仔細(xì)思考一下以下幾個(gè)問(wèn)題:
函數(shù)的調(diào)用過(guò)程為什么必須是一個(gè)棧?
IO過(guò)程在什么時(shí)間發(fā)生?調(diào)用發(fā)生時(shí),還是回調(diào)時(shí)?
回調(diào)函數(shù)從哪里調(diào)用?如果當(dāng)時(shí)利用工具去看上下文的話,調(diào)用棧是什么樣子的?
函數(shù)組件和返回值不知道你是否思考過(guò)為什么函數(shù)調(diào)用層級(jí)(上下文棧)會(huì)被表述為一個(gè)棧——是否有什么必要性,必須將函數(shù)調(diào)用的過(guò)程定義為一個(gè)棧呢?
原因就是返回值和同步順序。對(duì)于大部分函數(shù),我們需要得到函數(shù)計(jì)算的返回值。而要得到返回值,調(diào)用者就必須阻塞直到被調(diào)用者返回為止。因此調(diào)用者的執(zhí)行狀態(tài)就必須被保存,等到被調(diào)用者返回后繼續(xù)——從這點(diǎn)來(lái)說(shuō),調(diào)用其實(shí)是最樸素的上下文切換手段。而對(duì)于少部分無(wú)需返回的函數(shù),我們又往往需要他的順序外部效應(yīng)——例如干掉了某個(gè)進(jìn)程,開(kāi)了一個(gè)燈,或者僅僅是在環(huán)境變量里面添加了一項(xiàng)內(nèi)容。而順序外部效應(yīng)同樣需要等待被調(diào)用者返回以表明這個(gè)外部效應(yīng)已經(jīng)發(fā)生。
那么,如果我們不需要返回值也不需要順序的外部效應(yīng)呢?例如啟動(dòng)一個(gè)背景程序?qū)?shù)據(jù)發(fā)送到對(duì)端,無(wú)需保證發(fā)送成功的情況下?;蛘呤情_(kāi)始一個(gè)數(shù)據(jù)抓取行為,無(wú)需保證抓取的成功。
通常這種需求我們就湊合著用一個(gè)同步調(diào)用混過(guò)去了——反正問(wèn)題也不嚴(yán)重。但是對(duì)于阻塞相當(dāng)嚴(yán)重的情況而言,很多人還是會(huì)考慮到將這個(gè)行為做成異步過(guò)程。目前最流行的異步調(diào)用分解工具就是mq——不僅異步,而且分布。當(dāng)然,還有一個(gè)更簡(jiǎn)單的非分布方案——開(kāi)一個(gè)coroutine。
而CPS則是另一個(gè)方向——函數(shù)的返回值可以不返回調(diào)用者,而是返回給第三者。
IO 過(guò)程在什么時(shí)間發(fā)生其實(shí)這個(gè)問(wèn)題的核心在于——整個(gè)回調(diào)模型是基于多路復(fù)用的還是基于異步IO的?
原則上兩者都可以。你可以監(jiān)聽(tīng)fd就緒,也可以監(jiān)聽(tīng)I(yíng)O完成。當(dāng)然,即使監(jiān)聽(tīng)I(yíng)O完成,也不代表使用了內(nèi)核態(tài)異步接口。很可能只是用epoll封裝的而已。
回調(diào)函數(shù)的上下文環(huán)境這個(gè)問(wèn)題則需要和上面提到的“用戶態(tài)調(diào)度框架”結(jié)合起來(lái)說(shuō)。IO回調(diào)注冊(cè)的實(shí)質(zhì)是將回調(diào)函數(shù)綁定到某個(gè)fd上——就如同將coroutine綁定上去那樣。只是coroutine允許你順序的執(zhí)行,而callback則會(huì)切碎函數(shù)。當(dāng)然,大部分實(shí)現(xiàn)中,使用callback也有好處——coroutine的最小切換開(kāi)銷(xiāo)也在50ns,而call本身則只有2ns。
狀態(tài)機(jī)模型狀態(tài)機(jī)模型是一個(gè)更難于理解和編程的模型,其本質(zhì)是每次重入。
想像你是一個(gè)周期失憶的病人(就像“一周的朋友”那樣)。那么你如何才能完成一項(xiàng)需要跨越周期的工作呢?例如刺繡,種植作物,或者——交一個(gè)男朋友。
當(dāng)然,類(lèi)比到失憶病人的例子上必須有一點(diǎn)限制。正常的生活技能,還有一些常識(shí)性的東西必須不能在周期失憶范圍內(nèi)。例如重新學(xué)習(xí)認(rèn)字什么的可沒(méi)人受的了。
答案就是——做筆記。每次重復(fù)失憶后,你需要閱讀自己的筆記,觀察上次做到哪個(gè)步驟,下一個(gè)步驟是什么。這需要將一個(gè)工作分解為很多步驟,在每個(gè)步驟內(nèi)“重入”直到步驟完成,轉(zhuǎn)移到下一個(gè)狀態(tài)。
同理,在狀態(tài)機(jī)模型解法里,每次執(zhí)行都需要推演合適的狀態(tài),直到工作完成。這個(gè)模型已經(jīng)很少用到了,因?yàn)橄啾然卣{(diào)函數(shù)來(lái)說(shuō),狀態(tài)機(jī)模型更難理解和使用,性能差異也不大。
最后順帶一提,交一個(gè)男友的方案和其他幾個(gè)略有不同,主要靠顏好高冷反差萌,一般人就不要嘗試挑戰(zhàn)了。。。當(dāng)然一般人也不會(huì)一周失憶一次,畢竟生活不是韓劇也不是日本動(dòng)漫。。。
文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://www.ezyhdfw.cn/yun/37449.html
摘要:事件循環(huán)是異步編程的底層基石。對(duì)事件集合進(jìn)行輪詢,調(diào)用回調(diào)函數(shù)等一輪事件循環(huán)結(jié)束,循環(huán)往復(fù)。協(xié)程直接利用代碼的執(zhí)行位置來(lái)表示狀態(tài),而回調(diào)則是維護(hù)了一堆數(shù)據(jù)結(jié)構(gòu)來(lái)處理狀態(tài)。時(shí)代的協(xié)程技術(shù)主要是,另一個(gè)比較小眾。 Coding Crush Python開(kāi)發(fā)工程師 主要負(fù)責(zé)豈安科技業(yè)務(wù)風(fēng)險(xiǎn)情報(bào)系統(tǒng)redq。 引言 1.1. 存儲(chǔ)器山 存儲(chǔ)器山是 Randal Bryant 在《深入...
摘要:具有以下基本同步原語(yǔ)子進(jìn)程提供了通過(guò)創(chuàng)建和管理子進(jìn)程的。雖然隊(duì)列不是線程安全的,但它們被設(shè)計(jì)為專(zhuān)門(mén)用于代碼。表示異步操作的最終結(jié)果。 Python的asyncio是使用 async/await 語(yǔ)法編寫(xiě)并發(fā)代碼的標(biāo)準(zhǔn)庫(kù)。通過(guò)上一節(jié)的講解,我們了解了它不斷變化的發(fā)展歷史。到了Python最新穩(wěn)定版 3.7 這個(gè)版本,asyncio又做了比較大的調(diào)整,把這個(gè)庫(kù)的API分為了 高層級(jí)API和...
摘要:我們以請(qǐng)求網(wǎng)絡(luò)服務(wù)為例,來(lái)實(shí)際測(cè)試一下加入多線程之后的效果。所以,執(zhí)行密集型操作時(shí),多線程是有用的,對(duì)于密集型操作,則每次只能使用一個(gè)線程。說(shuō)到這里,對(duì)于密集型,可以使用多線程或者多進(jìn)程來(lái)提高效率。 為了提高系統(tǒng)密集型運(yùn)算的效率,我們常常會(huì)使用到多個(gè)進(jìn)程或者是多個(gè)線程,python中的Threading包實(shí)現(xiàn)了線程,multiprocessing 包則實(shí)現(xiàn)了多進(jìn)程。而在3.2版本的py...
摘要:一般用進(jìn)程池維護(hù),的設(shè)為數(shù)量。多線程爬蟲(chóng)多線程版本可以在單進(jìn)程下進(jìn)行異步采集,但線程間的切換開(kāi)銷(xiāo)也會(huì)隨著線程數(shù)的增大而增大。異步協(xié)程爬蟲(chóng)引入了異步協(xié)程語(yǔ)法。 Welcome to the D-age 對(duì)于網(wǎng)絡(luò)上的公開(kāi)數(shù)據(jù),理論上只要由服務(wù)端發(fā)送到前端都可以由爬蟲(chóng)獲取到。但是Data-age時(shí)代的到來(lái),數(shù)據(jù)是新的黃金,毫不夸張的說(shuō),數(shù)據(jù)是未來(lái)的一切?;诮y(tǒng)計(jì)學(xué)數(shù)學(xué)模型的各種人工智能的出現(xiàn)...
閱讀 1696·2019-08-30 15:44
閱讀 2624·2019-08-30 11:19
閱讀 465·2019-08-30 11:06
閱讀 1650·2019-08-29 15:27
閱讀 3130·2019-08-29 13:44
閱讀 1674·2019-08-28 18:28
閱讀 2407·2019-08-28 18:17
閱讀 2114·2019-08-26 10:41