進程和線程究竟是什么東西?傳統(tǒng)網(wǎng)絡(luò)服務(wù)模型是如何工作的?協(xié)程和線程的關(guān)系和區(qū)別有哪些?IO過程在什么時間發(fā)生? 在剛剛結(jié)束的 PyCon2014 上海站,來自七牛云存儲的 Python 高級工程師許智翔帶來了關(guān)于 Python 的分享《Python中的進程、線程、協(xié)程、同步、異步、回調(diào)》。 一、上下文切換技術(shù)簡述在進一步之前,讓我們先回顧一下各種上下文切換技術(shù)。 不過首先說明一點術(shù)語。當(dāng)我們說“上下文”的時候,指的是程序在執(zhí)行中的一個狀態(tài)。通常我們會用調(diào)用棧來表示這個狀態(tài)——棧記載了每個調(diào)用層級執(zhí)行到哪里,還有執(zhí)行時的環(huán)境情況等所有有關(guān)的信息。 當(dāng)我們說“上下文切換”的時候,表達的是一種從一個上下文切換到另一個上下文執(zhí)行的技術(shù)。而“調(diào)度”指的是決定哪個上下文可以獲得接下去的CPU時間的方法。 進程進程是一種古老而典型的上下文系統(tǒng),每個進程有獨立的地址空間,資源句柄,他們互相之間不發(fā)生干擾。 每個進程在內(nèi)核中會有一個數(shù)據(jù)結(jié)構(gòu)進行描述,我們稱其為進程描述符。這些描述符包含了系統(tǒng)管理進程所需的信息,并且放在一個叫做任務(wù)隊列的隊列里面。 很顯然,當(dāng)新建進程時,我們需要分配新的進程描述符,并且分配新的地址空間(和父地址空間的映射保持一致,但是兩者同時進入COW狀態(tài))。這些過程需要一定的開銷。 進程狀態(tài)忽略去linux內(nèi)核復(fù)雜的狀態(tài)轉(zhuǎn)移表,我們實際上可以把進程狀態(tài)歸結(jié)為三個最主要的狀態(tài):就緒態(tài),運行態(tài),睡眠態(tài)。這就是任何一本系統(tǒng)書上都有的三態(tài)轉(zhuǎn)換圖。 就緒和執(zhí)行可以互相轉(zhuǎn)換,基本這就是調(diào)度的過程。而當(dāng)執(zhí)行態(tài)程序需要等待某些條件(最典型就是IO)時,就會陷入睡眠態(tài)。而條件達成后,一般會自動進入就緒。 阻塞當(dāng)進程需要在某個文件句柄上做IO,這個fd又沒有數(shù)據(jù)給他的時候,就會發(fā)生阻塞。具體來說,就是記錄XX進程阻塞在了XX fd上,然后將進程標(biāo)記為睡眠態(tài),并調(diào)度出去。當(dāng)fd上有數(shù)據(jù)時(例如對端發(fā)送的數(shù)據(jù)到達),就會喚醒阻塞在fd上的進程。進程會隨后進入就緒隊列,等待合適的時間被調(diào)度。 阻塞后的喚醒也是一個很有意思的話題。當(dāng)多個上下文阻塞在一個fd上(雖然不多見,但是后面可以看到一個例子),而且fd就緒時,應(yīng)該喚醒多少個上下文呢?傳統(tǒng)上應(yīng)當(dāng)喚醒所有上下文,因為如果僅喚醒一個,而這個上下文又不能消費所有數(shù)據(jù)時,就會使得其他上下文處于無謂的死鎖中。 但是有個著名的例子——accept,也是使用讀就緒來表示收到的。如果試圖用多個線程來accept會發(fā)生什么?當(dāng)有新連接時,所有上下文都會就緒,但是只有第一個可以實際獲得fd,其他的被調(diào)度后又立刻阻塞。這就是驚群問題thundering herd problem。 現(xiàn)代linux內(nèi)核已經(jīng)解決了這個問題,方法驚人的簡單——accept方法加鎖。
線程線程是一種輕量進程,實際上在linux內(nèi)核中,兩者幾乎沒有差別,除了一點——線程并不產(chǎn)生新的地址空間和資源描述符表,而是復(fù)用父進程的。 二、傳統(tǒng)網(wǎng)絡(luò)服務(wù)模型進程模型為每個客戶分配一個進程。優(yōu)點是業(yè)務(wù)隔離,在一個進程中出現(xiàn)的錯誤不至于影響整個系統(tǒng),甚至其他進程。Oracle傳統(tǒng)上就是進程模型。缺點是進程的分配和釋放有非常高的成本。因此Oracle需要連接池來保持連接減少新建和釋放,同時盡量復(fù)用連接而不是隨意的新建連接。 線程模型為每客戶分配一個線程。優(yōu)點是更輕量,建立和釋放速度更快,而且多個上下文間的通訊速度非常快。缺點是一個線程出現(xiàn)問題容易將整個系統(tǒng)搞崩潰。 一個例子
在這個例子中,線程模式和進程模式可以輕易的互換。 如何工作的:
評價
性能thread模式,虛擬機:
fork模式,虛擬機:
thread模式,物理機:
注意在python中,雖然有GIL,但是一個線程陷入到網(wǎng)絡(luò)IO的時候,GIL是解鎖的。因此從調(diào)用開始到調(diào)用結(jié)束,減去CPU切換到其他上下文的時間,是可以多線程的?,F(xiàn)象是,在此種狀況下可以觀測到短暫的python CPU用量超過100%。 如果執(zhí)行多個上下文,可以充分利用這段時間。所觀測到的結(jié)果就是,只能單核的python,在小范圍內(nèi),其隨著并發(fā)數(shù)上升,性能居然會跟著上升。如果將這個過程轉(zhuǎn)移到一臺物理機上執(zhí)行,那么基本不能得出這樣的結(jié)論。這主要是因為虛擬機上內(nèi)核陷入的開銷更高。 三、C10K 問題描述當(dāng)同時連接數(shù)在10K左右時,傳統(tǒng)模型就不再適用。實際上在效率測試報告的線程切換開銷一節(jié)可以看到,超過1K后性能就差的一塌糊涂了。 進程模型的問題:在C10K的時候,啟動和關(guān)閉這么多進程是不可接受的開銷。事實上單純的進程fork模型在C1K時就應(yīng)當(dāng)拋棄了。 Apache的prefork模型,是使用預(yù)先分配(pre)的進程池。這些進程是被復(fù)用的。但即便是復(fù)用,本文所描述的很多問題仍不可避免。 線程模式的問題從任何測試都可以表明,線程模式比進程模式更耐久一些,性能更好。但是在面對C10K還是力不從心的。問題是,線程模式的問題出在哪里呢? 內(nèi)存?有些人可能認(rèn)為線程模型的失敗首先在于內(nèi)存。如果你這么認(rèn)為,一定是因為你查閱了非常老的資料,并且沒仔細(xì)思考過。 你可能看到資料說,一個線程棧會消耗8M內(nèi)存(linux默認(rèn)值,ulimit可以看到),512個線程棧就會消耗4G內(nèi)存,而10K個線程就是80G。所以首先要考慮調(diào)整棧深度,并考慮爆棧問題。 聽起來很有道理,問題是——linux的棧是通過缺頁來分配內(nèi)存的(How does stack allocation work in Linux?),不是所有棧地址空間都分配了內(nèi)存。因此,8M是最大消耗,實際的內(nèi)存消耗只會略大于實際需要的內(nèi)存(內(nèi)部損耗,每個在4k以內(nèi))。但是內(nèi)存一旦被分配,就很難回收(除非線程結(jié)束),這是線程模式的缺陷。 這個問題提出的前提是,32位下地址空間有限。雖然10K個線程不一定會耗盡內(nèi)存,但是512個線程一定會耗盡地址空間。然而這個問題對于目前已經(jīng)成為主流的64位系統(tǒng)來說根本不存在。 內(nèi)核陷入開銷?所謂內(nèi)核陷入開銷,就是指CPU從非特權(quán)轉(zhuǎn)向特權(quán),并且做輸入檢查的一些開銷。這些開銷在不同的系統(tǒng)上差異很大。 線程模型主要通過陷入切換上下文,因此陷入開銷大聽起來有點道理。實際上,這也是不成立的。線程在什么時候發(fā)生陷入切換?正常情況下,應(yīng)當(dāng)是IO阻塞的時候。同樣的IO量,難道其他模型就不需要陷入了么?只是非阻塞模型有很大可能直接返回,并不發(fā)生上下文切換而已。 效率測試報告的基礎(chǔ)調(diào)用開銷一節(jié),證實了當(dāng)代操作系統(tǒng)上內(nèi)核陷入開銷是非常驚人的小的(10個時鐘周期這個量級)。 線程模型的問題在于切換成本高熟悉linux內(nèi)核的應(yīng)該知道,近代linux調(diào)度器經(jīng)過幾個階段的發(fā)展。
實際上直到O(1),調(diào)度器的調(diào)度復(fù)雜度才和隊列長度無關(guān)。在此之前,過多的線程會使得開銷隨著線程數(shù)增長(不保證線性)。 O(1)調(diào)度器看起來似乎是完全不隨著線程的影響。但是這個調(diào)度器有顯著的缺點——難于理解和維護,并且在一些情況下會導(dǎo)致交互式程序響應(yīng)緩慢。 因此,每當(dāng)線程試圖讀寫網(wǎng)絡(luò),并遇到阻塞時,都會發(fā)生O(logm)級別的開銷。而且每次收到報文,喚醒阻塞在fd上的上下文時,同樣要付出O(logm)級別的開銷。 分析O(logm)的開銷看似并不大,但是卻是一個無法接受的開銷。因為IO阻塞是一個經(jīng)常發(fā)生的事情。每次IO阻塞,都會發(fā)生開銷。而且決定活躍線程數(shù)的是用戶,這不是我們可控制的。更糟糕的是,當(dāng)性能下降,響應(yīng)速度下降時。同樣的用戶數(shù)下,活躍上下文會上升(因為響應(yīng)變慢了)。這會進一步拉低性能。 問題的關(guān)鍵在于,http服務(wù)并不需要對每個用戶完全公平,偶爾某個用戶的響應(yīng)時間大大的延長了是可以接受的。在這種情況下,使用紅黑樹去組織待處理fd列表(其實是上下文列表),并且反復(fù)計算和調(diào)度,是無謂的開銷。 四、多路復(fù)用簡述要突破C10K問題,必須減少系統(tǒng)內(nèi)活躍上下文數(shù)(其實未必,例如換一個調(diào)度器,例如使用RT的SCHED_RR),因此就要求一個上下文同時處理多個鏈接。而要做到這點,就必須在每次系統(tǒng)調(diào)用讀取或?qū)懭霐?shù)據(jù)時立刻返回。否則上下文持續(xù)阻塞在調(diào)用上,如何能夠復(fù)用?這要求fd處于非阻塞狀態(tài),或者數(shù)據(jù)就緒。 上文所說的所有IO操作,其實都特指了他的阻塞版本。所謂阻塞,就是上下文在IO調(diào)用上等待直到有合適的數(shù)據(jù)為止。這種模式給人一種“只要讀取數(shù)據(jù)就必定能讀到”的感覺。而非阻塞調(diào)用,就是上下文立刻返回。如果有數(shù)據(jù),帶回數(shù)據(jù)。如果沒有數(shù)據(jù),帶回錯誤(EAGAIN)。因此,“雖然發(fā)生錯誤,但是不代表出錯”。 但是即使有了非阻塞模式,依然繞不過就緒通知問題。如果沒有合適的就緒通知技術(shù),我們只能在多個fd中盲目的重試,直到碰巧讀到一個就緒的fd為止。這個效率之差可想而知。 在就緒通知技術(shù)上,有兩種大的模式——就緒事件通知和異步IO。其差別簡要來說有兩點。就緒通知維護一個狀態(tài),由用戶讀取。而異步IO由系統(tǒng)調(diào)用用戶的回調(diào)函數(shù)。就緒通知在數(shù)據(jù)就緒時就生效,而異步IO直到數(shù)據(jù)IO完成才發(fā)生回調(diào)。 linux下的主流方案一直是就緒通知,其內(nèi)核態(tài)異步IO方案甚至沒有被封裝到glibc里去。圍繞就緒通知,linux總共提出過三種解決方案。我們繞過select和poll方案,看看epoll方案的特性。 另外提一點。有趣的是,當(dāng)使用了epoll后(更準(zhǔn)確說只有在LT模式下),fd是否為非阻塞其實已經(jīng)不重要了。因為epoll保證每次去讀取的時候都能讀到數(shù)據(jù),因此不會阻塞在調(diào)用上。 epoll用戶可以新建一個epoll文件句柄,并且將其他fd和這個"epoll fd"關(guān)聯(lián)。此后可以通過epoll fd讀取到所有就緒的文件句柄。 epoll有兩大模式,ET和LT。LT模式下,每次讀取就緒句柄都會讀取出完整的就緒句柄。而ET模式下,只給出上次到這次調(diào)用間新就緒的句柄。換個說法,如果ET模式下某次讀取出了一個句柄,這個句柄從未被讀取完過——也就是從沒有從就緒變?yōu)槲淳途w。那么這個句柄就永遠不會被新的調(diào)用返回,哪怕上面其實充滿了數(shù)據(jù)——因為句柄無法經(jīng)歷從非就緒變?yōu)榫途w的過程。 類似CFS,epoll也使用了紅黑樹——不過是用于組織加入epoll的所有fd。epoll的就緒列表使用的是雙向隊列。這方便系統(tǒng)將某個fd加入隊列中,或者從隊列中解除。 要進一步了解epoll的具體實現(xiàn),可以參考這篇linux下poll和epoll內(nèi)核源碼剖析。 性能如果使用非阻塞函數(shù),就不存在阻塞IO導(dǎo)致上下文切換了,而是變?yōu)闀r間片耗盡被搶占(大部分情況下如此),因此讀寫的額外開銷被消除。而epoll的常規(guī)操作,都是O(1)量級的。而epoll wait的復(fù)制動作,則和當(dāng)前需要返回的fd數(shù)有關(guān)(在LT模式下幾乎就等同于上面的m,而ET模式下則會大大減少)。 但是epoll存在一點細(xì)節(jié)問題。epoll fd的管理使用紅黑樹,因此在加入和刪除時需要O(logn)復(fù)雜度(n為總連接數(shù)),而且關(guān)聯(lián)操作還必須每個fd調(diào)用一次。因此在大連接量下頻繁建立和關(guān)閉連接仍然有一定性能問題(超短連接)。不過關(guān)聯(lián)操作調(diào)用畢竟比較少。如果確實是超短連接,tcp連接和釋放開銷就很難接受了,所以對總體性能影響不大。 固有缺陷原理上說,epoll實現(xiàn)了一個wait_queue的回調(diào)函數(shù),因此原理上可以監(jiān)聽任何能夠激活wait_queue的對象。但是epoll的最大問題是無法用于普通文件,因為普通文件始終是就緒的——雖然在讀取的時候不是這樣。 這導(dǎo)致基于epoll的各種方案,一旦讀到普通文件上下文仍然會阻塞。golang為了解決這個問題,在每次調(diào)用syscall的時候,會獨立的啟動一個線程,在獨立的線程中進行調(diào)用。因此golang在IO普通文件的時候網(wǎng)絡(luò)不會阻塞。 五、事件通知機制下的幾種程序設(shè)計模型簡述使用通知機制的一大缺憾就是,用戶進行IO操作后會陷入茫然——IO沒有完成,所以當(dāng)前上下文不能繼續(xù)執(zhí)行。但是由于復(fù)用線程的要求,當(dāng)前線程還需要接著執(zhí)行。所以,在如何進行異步編程上,又分化出數(shù)種方案。 用戶態(tài)調(diào)度首先需要知道的一點就是,異步編程大多數(shù)情況下都伴隨著用戶態(tài)調(diào)度問題——即使不使用上下文技術(shù)。 因為系統(tǒng)不會自動根據(jù)fd的阻塞狀況來喚醒合適的上下文了,所以這個工作必須由其他人——一般就是某種框架——來完成。 你可以想像一個fd映射到對象的大map表,當(dāng)我們從epoll中得知某個fd就緒后,需要喚醒某種對象,讓他處理fd對應(yīng)的數(shù)據(jù)。 當(dāng)然,實際情況會更加復(fù)雜一些。原則上所有不占用CPU時間的等待都需要中斷執(zhí)行,陷入睡眠,并且交由某種機構(gòu)管理,等待合適的機會被喚醒。例如sleep,或是文件IO,還有l(wèi)ock。更精確的說,所有在內(nèi)核里面涉及到wait_queue的,在框架里面都需要做這種機制——也就是把內(nèi)核的調(diào)度和等待搬到用戶態(tài)來。 當(dāng)然,其實也有反過來的方案——就是把程序扔到內(nèi)核里面去。其中最著名的實例大概是微軟的http服務(wù)器了。 這個所謂的“可喚醒可中斷對象”,用的最多的就是協(xié)程。 協(xié)程協(xié)程是一種編程組件,可以在不陷入內(nèi)核的情況進行上下文切換。如此一來,我們就可以把協(xié)程上下文對象關(guān)聯(lián)到fd,讓fd就緒后協(xié)程恢復(fù)執(zhí)行。 如何做到這是如何做到的呢? 我們在內(nèi)核里實行上下文切換的時候,其實是將當(dāng)前所有寄存器保存到內(nèi)存中,然后從另一塊內(nèi)存中載入另一組已經(jīng)被保存的寄存器。對于圖靈機來說,當(dāng)前狀態(tài)寄存器意味著機器狀態(tài)——也就是整個上下文。其余內(nèi)容,包括棧上內(nèi)存,堆上對象,都是直接或者間接的通過寄存器來訪問的。 但是請仔細(xì)想想,寄存器更換這種事情,似乎不需要進入內(nèi)核態(tài)么。事實上我們在用戶態(tài)切換的時候,就是用了類似方案。 C coroutine的實現(xiàn),基本大多是保存現(xiàn)場和恢復(fù)之類的過程。python則是保存當(dāng)前thread的top frame(greenlet)。 但是非常悲劇的,純用戶態(tài)方案(setjmp/longjmp)在多數(shù)系統(tǒng)上執(zhí)行的效率很高,但是并不是為了協(xié)程而設(shè)計的。setjmp并沒有拷貝整個棧(大多數(shù)的coroutine方案也不應(yīng)該這么做),而是只保存了寄存器狀態(tài)。這導(dǎo)致新的寄存器狀態(tài)和老寄存器狀態(tài)共享了同一個棧,從而在執(zhí)行時互相破壞。而完整的coroutine方案應(yīng)當(dāng)在特定時刻新建一個棧。 而比較好的方案(makecontext/swapcontext)則需要進入內(nèi)核(sigprocmask),這導(dǎo)致整個調(diào)用的性能非常低。 協(xié)程與線程的關(guān)系首先我們可以明確,協(xié)程不能調(diào)度其他進程中的上下文。而后,每個協(xié)程要獲得CPU,都必須在線程中執(zhí)行。因此,協(xié)程所能利用的CPU數(shù)量,和用于處理協(xié)程的線程數(shù)量直接相關(guān)。 作為推論,在單個線程中執(zhí)行的協(xié)程,可以視為單線程應(yīng)用。這些協(xié)程,在未執(zhí)行到特定位置(基本就是阻塞操作)前,是不會被搶占,也不會和其他CPU上的上下文發(fā)生同步問題的。因此,一段協(xié)程代碼,中間沒有可能導(dǎo)致阻塞的調(diào)用,執(zhí)行在單個線程中。那么這段內(nèi)容可以被視為同步的。 我們經(jīng)常可以看到某些協(xié)程應(yīng)用,一啟動就是數(shù)個進程。這并不是跨進程調(diào)度協(xié)程。一般來說,這是將一大群fd分給多個進程,每個進程自己再做fd-協(xié)程對應(yīng)調(diào)度。 基于就緒通知的協(xié)程框架
這樣,異步的數(shù)據(jù)讀寫動作,在我們的想像中就可以變?yōu)橥降?。而我們知道同步模型會極大降低我們的編程負(fù)擔(dān)。 CPS模型其實這個模型有個更流行的名字——回調(diào)模型。之所以扯上CPS這么高大上的玩意,主要是里面涉及不少有趣的話題。 首先是回調(diào)模型的大致過程。在IO調(diào)用的時候,同時傳入一個函數(shù),作為返回函數(shù)。當(dāng)IO結(jié)束時,調(diào)用傳入的函數(shù)來處理下面的流程。這個模型聽起來挺簡單的。 然后是CPS。用一句話來描述這個模型——他把一切操作都當(dāng)作了IO,無論干什么,結(jié)果要通過回調(diào)函數(shù)來返回。從這個角度來說,IO回調(diào)模型只能被視作CPS的一個特例。 例如,我們需要計算1+2*3,在cps里面就需要這么寫:
其中mul和add在python里面如下定義:
而且由于python沒有TCO,所以這樣的寫法會產(chǎn)生非常多的frame。 但是要正確理解這個模型,你需要仔細(xì)思考一下以下幾個問題:
函數(shù)組件和返回值不知道你是否思考過為什么函數(shù)調(diào)用層級(上下文棧)會被表述為一個?!欠裼惺裁幢匾?,必須將函數(shù)調(diào)用的過程定義為一個棧呢? 原因就是返回值和同步順序。對于大部分函數(shù),我們需要得到函數(shù)計算的返回值。而要得到返回值,調(diào)用者就必須阻塞直到被調(diào)用者返回為止。因此調(diào)用者的執(zhí)行狀態(tài)就必須被保存,等到被調(diào)用者返回后繼續(xù)——從這點來說,調(diào)用其實是最樸素的上下文切換手段。而對于少部分無需返回的函數(shù),我們又往往需要他的順序外部效應(yīng)——例如干掉了某個進程,開了一個燈,或者僅僅是在環(huán)境變量里面添加了一項內(nèi)容。而順序外部效應(yīng)同樣需要等待被調(diào)用者返回以表明這個外部效應(yīng)已經(jīng)發(fā)生。 那么,如果我們不需要返回值也不需要順序的外部效應(yīng)呢?例如啟動一個背景程序?qū)?shù)據(jù)發(fā)送到對端,無需保證發(fā)送成功的情況下?;蛘呤情_始一個數(shù)據(jù)抓取行為,無需保證抓取的成功。 通常這種需求我們就湊合著用一個同步調(diào)用混過去了——反正問題也不嚴(yán)重。但是對于阻塞相當(dāng)嚴(yán)重的情況而言,很多人還是會考慮到將這個行為做成異步過程。目前最流行的異步調(diào)用分解工具就是mq——不僅異步,而且分布。當(dāng)然,還有一個更簡單的非分布方案——開一個coroutine。 而CPS則是另一個方向——函數(shù)的返回值可以不返回調(diào)用者,而是返回給第三者。 IO 過程在什么時間發(fā)生其實這個問題的核心在于——整個回調(diào)模型是基于多路復(fù)用的還是基于異步IO的? 原則上兩者都可以。你可以監(jiān)聽fd就緒,也可以監(jiān)聽IO完成。當(dāng)然,即使監(jiān)聽IO完成,也不代表使用了內(nèi)核態(tài)異步接口。很可能只是用epoll封裝的而已。 回調(diào)函數(shù)的上下文環(huán)境這個問題則需要和上面提到的“用戶態(tài)調(diào)度框架”結(jié)合起來說。IO回調(diào)注冊的實質(zhì)是將回調(diào)函數(shù)綁定到某個fd上——就如同將coroutine綁定上去那樣。只是coroutine允許你順序的執(zhí)行,而callback則會切碎函數(shù)。當(dāng)然,大部分實現(xiàn)中,使用callback也有好處——coroutine的最小切換開銷也在50ns,而call本身則只有2ns。 狀態(tài)機模型狀態(tài)機模型是一個更難于理解和編程的模型,其本質(zhì)是每次重入。 想像你是一個周期失憶的病人(就像“一周的朋友”那樣)。那么你如何才能完成一項需要跨越周期的工作呢?例如刺繡,種植作物,或者——交一個男朋友。 當(dāng)然,類比到失憶病人的例子上必須有一點限制。正常的生活技能,還有一些常識性的東西必須不能在周期失憶范圍內(nèi)。例如重新學(xué)習(xí)認(rèn)字什么的可沒人受的了。 答案就是——做筆記。每次重復(fù)失憶后,你需要閱讀自己的筆記,觀察上次做到哪個步驟,下一個步驟是什么。這需要將一個工作分解為很多步驟,在每個步驟內(nèi)“重入”直到步驟完成,轉(zhuǎn)移到下一個狀態(tài)。 同理,在狀態(tài)機模型解法里,每次執(zhí)行都需要推演合適的狀態(tài),直到工作完成。這個模型已經(jīng)很少用到了,因為相比回調(diào)函數(shù)來說,狀態(tài)機模型更難理解和使用,性能差異也不大。 最后順帶一提,交一個男友的方案和其他幾個略有不同,主要靠顏好高冷反差萌,一般人就不要嘗試挑戰(zhàn)了。。。當(dāng)然一般人也不會一周失憶一次,畢竟生活不是韓劇也不是日本動漫。。。 |
|