云風-coroutine源碼解析

??????最近不知道咋回事突然對協程的原理挺感興趣,雖然之前也學習過Go語言,接觸過協程,但是其實并不太了解具體的原理,尤其看到知乎上stackless與stackful之間作比較的文章,個人表示真心看不懂,但是帶著越是看不懂越要裝逼的沖動,上網查了查一些協程的實現,目前感覺比較知名的有:微信libcolibgo、Go語言作者之一的Russ Cox個人寫的libtaskJava協程庫以及本文要剖析的云風寫的coroutine。不廢話,先上圖:

coroutine.png

從上圖可以看出coroutine有以下兩個特點:
第一:代碼少,僅僅用不到300行代碼就實現了協程的核心邏輯,對此只有一個大寫的服。
第二:用純C語言寫的(沒有c++);說實話,個人自認為C語言學得還行,但是C++老是學不會。
看懂了這個,我感覺就可以看看其他稍微復雜點的實現(加入了poll等 因為目前這個簡單的還沒法做到IO阻塞時自動yield)。

在看之前,先聊點必備知識點,這是理解協程實現的核心。
getcontext、setcontext、makecontext、swapcontext
這四個函數是Linux提供的系統函數(忘了跟大家說了 云風這個版本只可以在linux下運行 雖然windows也有fiber這種東西 但是我感覺應該木人感興趣....),理解了這四個函數,其實這個代碼基本80%的難點就搞定了(剩下20%在于要理解函數調用的棧機制)。
其實context這個詞在編程中是個很重要的詞,Java Spring框架中有ApplicationContext、OSGI框架中有BundleContext、線程也有自己存活的一個Context等等,其實context就是代表所必需的一個環境信息,有了context就可以決定你的一切走向。簡單說,如果你所需要的僅僅是一個變量a=1,那么我們也可以說a=1就是你這個場景下的context。再比如線程切換的時候,總不能讓線程切換回來的時候重新開始吧,他曾經走到過的地方,修改過的變量,總得給人家保存到一個地方吧,那么這些需要保存的信息其實也是這個線程能繼續運行所依賴的context。只不過線程切換的涉及到CPU ring的變化(ring3 用戶態 ring0 內核態),此時比較難處理的是每個ring下有自己的特用棧,而協程-作為輕量級的線程,不涉及到CPU ring的轉變,僅僅在用戶態模擬了這種切換(待會兒分析完coroutine就知道啥意思啦),這也是協程會比線程性能高的本質原因所在(Do less)。

getcontext,顧名思義,將當前context保存起來;setcontext,改變當前的context;makecontext這個畢竟牛逼,是制作一個你想要的context;最后swapcontext從一個context切換到另外一個context。先來個簡單例子練練手:

#include <stdio.h>
#include <ucontext.h>

void main(){
        char isVisit = 0;
        ucontext_t context1, context2; //ucontext_t也是linux提供的類型 用來存儲當前context的
        getcontext(&context1);
        printf("I come here\n");

        if(!isVisit){
                isVisit = 1;
                swapcontext(&context2, &context1);
        }

        printf("end of main!");
}

猜猜這個執行結果是啥? 先上答案:

result.png

可以看出 I come here執行了兩次, why?我們來一句一句的解釋:首先聲明了兩個變量context1和context2,可以把這兩個變量理解為容器,專門用來放當前環境的,第三行的時候執行了getcontext(&context1)意思是把當前上下文環境保存到context1這個變量中,然后打印了第一次“I come here”,然后運行到isVisit,因為一開始isVisit=0,所以會進入if語句之中,運行到swapcontext時候,這個函數的含義是把當前上下文環境保存到context2之中,同時跳到context1對應的上下文環境之中。由于context1當時保存的是getcontext調用時的上下文環境中(運行程序到第幾行也是上下文環境中一個元素),所以swapcontext調用完畢后,又會切換到printf對應的這一行,再次打印出第二次的“I come here”,但是此時之前isVisit已經設置為1了,所以if不會進入了,直接到“end of main”了,程序結束!其實這里還是有個東西沒有說清楚,上下文環境到底包含什么? 我們知道真正執行指令歸根結底還是CPU,而CPU在運行程序時會借助于很多寄存器,比如EIP(永遠指向下一條要執行程序的地址)、ESP(stack pointer棧指針)、EBP(base pointer基址寄存器)等等,這些其實就是所謂的上下文環境,比如EIP,程序在執行時要靠這個寄存器指示下一步去執行哪條指令,如果你切換到其他協程(線程也如此),回來的時候EIP已經找不到了,那你不悲劇了?那協程(線程也如此)豈不是又要重頭來過?那這個時候顯然需要一塊內存去把這個上下文環境保存住,下次回來的時候從內存拿出來接著執行就行,就跟沒有切換一個樣。針對協程來說,上文Linux提供的ucontext_t這個變量就起到這個存儲上下文的作用(線程切換的上下文存儲涉及到ring的改變 由操作系統完成的 而協程切換上下文保存需要咱們自己搞定 操作系統根本不參與 不過這樣也減少了用戶態與內核態的切換)。結合這段話再回頭看看上面的程序,應該會有新的體會。
Let us move forward,繼續再看下面一段程序:

#include <stdio.h>
#include <ucontext.h>

#define STACK_SIZE (1024*1024)
char stack[STACK_SIZE];


void test(int a){
  char dummy = 12;
  int    hello     = 9;
  printf("test %d\n",a);
}

void main(){
    char isVisit = 0;
    ucontext_t context1, context2;
    getcontext(&context1);
    printf("I come here\n");
    context1.uc_stack.ss_sp = stack;
    context1.uc_stack.ss_size = STACK_SIZE;
    context1.uc_link = &context2;
    makecontext(&context1, (void (*)(void))test, 1, 10);

    if(!isVisit){
        isVisit = 1;
        swapcontext(&context2, &context1);
        printf("I come to if!\n");
    }

    printf("end of main!\n");
}

先上結果:

I come here
test 10
I come to if!
end of main!

這里又加了一個makecontext,這個可是context函數界的老大哥,協程核心全靠他,getcontext只是獲取一個當前上下文,而makecontext卻能在得到當前上下文的基礎上對其進行社會主義改造,比如我們上面這段代碼中修改了棧的信息,讓test函數執行的時候使用stack[STACK_SIZE]作為其棧空間(函數調用過程需要棧保存返回地址 函數參數 局部變量),makecontext第三個參數代表test函數需要幾個參數,因為我們的test函數只需要一個int a,所以這里傳入1,后面的10就是具體參數值,再比如coroutine源碼中有下面一個片段:

makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));

想必大家也知道第三個參數2啥意思了。這里比較重要的是兩個點:
1. makecontext中第二個參數是一個函數指針,意思是當context1生效時便執行test函數(可以理解為test函數執行的上下文環境就是context1)。
2. 當test函數執行完畢后,會跳到context1.uc_link對應的環境上下文(上面代碼設置為context2對應的上下文)。
這兩點需要用心體會,結合上述代碼便可以更好的理解。當然,我們這里用gdb調試一下,幫忙大家理解這個過程(有意搞了一個dummy和hello變量在test函數 貌似沒有用 實則很有用)。

具體操作見下圖:

image.png

幾個注意點,一是注意gcc 別忘了加上-g參數,這是為了生成可以調試的信息,否則gdb無法調試。gdb常用的調試命令可以參照 gdb tutorial
常用就幾個:b 加斷點,比如上圖中我在test函數加了一個斷點,r是讓程序跑起來,由于test加了斷點,所以在test函數第一行代碼停了下來(顯示的這一行代碼是馬上要執行但是還未執行的),n是單步運行,但是碰到函數并不會進入,s也是單步運行,但是碰到函數可以step into進入到函數內部調試。p經常使用,打印變量的值,有時還使用p/x 將打印的值以16進制顯示。知道這些就夠用,接著看重要的細節:

  1. p stack+0 打印stack的地址為 0x601080, p stack+1024*1024打印的地址為0x701080,而變量dummy的地址為0x70105f,顯然這個地址正合適在stack+0與stack+1024*1024之間,我們知道局部變量都是在棧上分配的,顯然test函數已經使用stack作為棧來使用了,這也驗證了makecontext制作的上下文環境已經在生效。

2.如果dummy是在棧上分配的,那么下一個變量hello肯定也應該是在棧上分配吧。注意看hello的地址0x701058顯然也是在stack范圍內的。注意為啥hello地址比dummy地址少8呢?畢竟先聲明的是dummy變量,然后才是hello呀?
這里有兩個點:一是少,少是因為現在操作系統的棧增長方向都是像地址減少的方向增加的,這個是目前通用的實現,至于為啥無從考究。也就是說,當我們調用push命令壓棧一個元素,地址是減少的;二是為啥少8,那是因為目前我使用的linux是64位的,棧一個元素占用8個字節,雖然char只有一個字節,但是為了內存對齊(方便硬件讀取 對齊后讀取快),還是會在棧上給char分配8個字節(如果之前學過數字電子線路的同學應該知道內存地址編碼的問題,如果不對齊,硬件會花費多次才能讀出想要的內容,所以現在一般都會提高讀取速度進行內存對齊)。
既然說到棧了,其實棧是一種很有趣的數據結構,太多東西都用到它了,比如JVM對字節碼的執行、Vue源碼中解析模板生成AST、spring解析@Configure、@Import注解、Tomcat對xml的解析(Digester類)等等,太多棧的使用場景,這可不是一句簡單的先進后出可以概括的,里面有很多深刻的內容。而在函數調用這個場景里,操作系統通常會利用棧存儲返回地址、函數參數、局部變量等消息(詳細分析可以參考 函數如何使用棧),我們只看一個比較重要的圖:

我在原圖基礎上加上高低地址的說明,比如main函數里面調用func_A,func_A調用func_B,此時棧的結構就如上圖所示,我們可以發現棧里面有很多重要的東西,返回地址(要不然執行完函數你根本不知道返回到哪兒 有了這個pop一下就拿到了返回地址)、局部變量(Java里面非逃逸對象也是直接在棧上分配喲 減輕GC的壓力)等,圖上還有個棧幀的概念,這個其實是軟件層面的界定,比如main用到的棧的范圍就是main棧幀,其他函數類似,有了這個東西可以對某個函數能夠使用的棧空間進行一定的界定。這不是重點所在,重點就是要知道
1.棧是往地址小的方向增長的(但這不是說棧只往地址小的方向走,而是說用到的時候往小的地方走,函數調用完了,這個函數對應的棧幀就沒用了,此時把棧指針加上一定的size即可,這時棧指針就往大的方向走啦)
2.局部變量是在棧上分配的

ok 要看懂coroutine源碼所需要的知識都說完了,上源碼吧,先看看調度器以及協程(調度器負責協程切換的上下文保護工作)的結構定義:

coroutine.c:
struct schedule {
    char stack[STACK_SIZE]; //棧空間
    ucontext_t main; //存儲主上下文
    int nco; //存儲目前開了多少個協程
    int cap;//容量 如果不夠了 會動態擴容 下面有實現
    int running;//目前調度器正在運行著的協程的id
    struct coroutine **co;//指向一個指針數組其中數組每個元素又指向一個協程的信息
    //(協程信息看下面這個結構體)
};

struct coroutine {
    coroutine_func func; //協程要執行的函數體 
    void *ud;//
    ucontext_t ctx;//存儲當前協程所對應的上下文信息
    struct schedule * sch;//指向調度器 見上面的結構體 每個協程總得知道是誰在調度自己吧
    ptrdiff_t cap;//自己的棧的容量
    ptrdiff_t size;//自己的棧的真實使用size
    int status;//目前協程的狀態 源碼中有下面四種狀態
    char *stack;//當前協程所使用的棧的指針
};


coroutine.h:
//協程的所有可能狀態
#define COROUTINE_DEAD 0
#define COROUTINE_READY 1
#define COROUTINE_RUNNING 2
#define COROUTINE_SUSPEND 3

說一些細節:
1 schedule結構體中的棧信息是實打實的一個數組,但是coroutine里的棧卻是棧指針,為啥這樣呢?這就是coroutine的奧妙之處,每個協程都使用調度器里面的空間作為其棧信息 ,然后調度到其他協程時,再為此協程分配內存,將調度器的棧的信息拷貝自己的的stack指針下保存起來,下次再調度自己時,再把stack指針的內容原樣拷貝到調度器的stack數組里面,達到圓潤切換的目的。

  1. 可以看到struct coroutine里面有個ptrdiff_t的類型,這是個Linux系統提供的類型,意思是存儲指針做減法后的數據類型,因為這兩個字段是通過兩個指針做減法算出來的,后面可以明白這里的精巧用心。

接著讓我們看看初始化調度器的代碼:

coroutine.c:
#define DEFAULT_COROUTINE 16
struct schedule * 
coroutine_open(void) {
    struct schedule *S = malloc(sizeof(*S));
    S->nco = 0;
    S->cap = DEFAULT_COROUTINE;
    S->running = -1;
    S->co = malloc(sizeof(struct coroutine *) * S->cap);
    memset(S->co, 0, sizeof(struct coroutine *) * S->cap);
    return S;
}

這里代碼邏輯比較簡單,利用malloc為調度器分配空間,然后賦上初值,然后為調度器里面的協程數組先分配默認16個空間,可以看到cap為16,nco為0。這就是一種預分配策略,就像Java里面的ArrayList似的,先初始化一定大小,然后等數據超過擦破大小,再動態擴容。coroutine里面動態擴容的邏輯在:

coroutine.c:
int 
coroutine_new(struct schedule *S, coroutine_func func, void *ud) {
    struct coroutine *co = _co_new(S, func , ud);
    if (S->nco >= S->cap) {
        int id = S->cap;
        S->co = realloc(S->co, S->cap * 2 * sizeof(struct coroutine *));
        memset(S->co + S->cap , 0 , sizeof(struct coroutine *) * S->cap);
        S->co[S->cap] = co;
        S->cap *= 2;
        ++S->nco;
        return id;
    } else {
        int i;
        for (i=0;i<S->cap;i++) {
            int id = (i+S->nco) % S->cap;
            if (S->co[id] == NULL) {
                S->co[id] = co;
                ++S->nco;
                return id;
            }
        }
    }
    assert(0);
    return -1;
}

這個函數的作用是為調度器生成新的協程,可以看到if的判斷,當協程的數量要超過容量cap時,用了一個系統函數realloc重新再申請一塊更大的內存(申請的大小是之前cap的兩倍)。
可以順路看一下Java ArrayList的擴容玩法:

    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

新的容量=老的容量+老的容量>>1, 右移相當于除以2,新容量大約是老容量的1.5倍。
ok,到了coroutine最精彩的一部分啦:

coroutine.c:
//此函數的作用是 讓調度器運行協程號為id的協程的運行
void 
coroutine_resume(struct schedule * S, int id) {
    assert(S->running == -1);
    assert(id >=0 && id < S->cap);
    struct coroutine *C = S->co[id];//通過協程id拿到協程結構體信息 這個id是建立協程時返回的
    if (C == NULL)
        return;
    int status = C->status;
    switch(status) {
       /**有兩種狀態時可以讓協程恢復運行 一是剛建立的時候 這時線程狀態是READY
 但是由于剛建立 棧信息還沒有配置好  所以利用getcontext以及makecontext為其配置上下文**/
    case COROUTINE_READY:
        getcontext(&C->ctx);
        C->ctx.uc_stack.ss_sp = S->stack;
        C->ctx.uc_stack.ss_size = STACK_SIZE;
        C->ctx.uc_link = &S->main;
        S->running = id;
        C->status = COROUTINE_RUNNING;
        uintptr_t ptr = (uintptr_t)S;
        makecontext(&C->ctx, (void (*)(void)) mainfunc, 2, (uint32_t)ptr, (uint32_t)(ptr>>32));
        swapcontext(&S->main, &C->ctx);
        break;
 /**還有一種就是之前運行過了,然后被調度器調走了,運行別的協程了,
再次回來運行此協程時 狀態變為SUSPEND 這個時候因為剛建立時棧信息已經配置好了 所以這里不需要makecontext配置棧信息了 
只需要swapcontext切換到此協程就ok**/
    case COROUTINE_SUSPEND:
        memcpy(S->stack + STACK_SIZE - C->size, C->stack, C->size);//將協程內部的棧數據拷貝到調度器的棧空間 這是為恢復本協程的執行做準備
        S->running = id;
        C->status = COROUTINE_RUNNING;
        swapcontext(&S->main, &C->ctx);
        break;
    default:
        assert(0);
    }
}

關鍵就是swapcontext進行上下文的切換,當切換到C->ctx時,由于之前為C->ctx配置的函數名是mainfunc,所以一切換就相當于下一步去執行mainfunc啦,同時2代表有兩個參數,然后后面兩個是調度器結構的地址,由于是64位的機器,這里將高低32位分別放到兩個變量里傳給mainfunc(這里如果不太明白繼續看開頭的例子)。
而mainfunc我們可以想見,肯定要去調用協程關聯的那個函數:

coroutine.c:
static void
mainfunc(uint32_t low32, uint32_t hi32) {
    uintptr_t ptr = (uintptr_t)low32 | ((uintptr_t)hi32 << 32);
    struct schedule *S = (struct schedule *)ptr;
    int id = S->running;
    struct coroutine *C = S->co[id];
    C->func(S,C->ud);//調用協程要處理的業務函數 你自己寫的
    _co_delete(C);
    S->co[id] = NULL;
    --S->nco;
    S->running = -1;
}

關鍵一步我已經加上注釋,執行完協程肯定要銷毀資源,下面那些都是在將調度器此協程id對應的協程結構體銷毀掉。還有個小細節,這個函數作者前面加了一個static,由于此函數只在本文件(coroutine.c)內部使用,所以作者加了static(有點像private那種feel static修飾的函數只可以在聲明所在的文件內部使用)。

最最精彩的來了,協程既然要切換,肯定要有個方法讓出CPU吧,here it comes:

coroutine.c:
void
coroutine_yield(struct schedule * S) {
    int id = S->running;
    assert(id >= 0);
    struct coroutine * C = S->co[id];
    assert((char *)&C > S->stack);
    _save_stack(C,S->stack + STACK_SIZE);//讓出CPU最牛逼的一句 將當前棧信息保存到協程結構體里面的char *stack那個指針里面
    C->status = COROUTINE_SUSPEND;//讓出CPU后 狀態自然要是SUSPEND
    S->running = -1;
    swapcontext(&C->ctx , &S->main);//正式讓出CPU
}

static void
_save_stack(struct coroutine *C, char *top) {
    char dummy = 0;//神來之筆
    assert(top - &dummy <= STACK_SIZE);
    if (C->cap < top - &dummy) {
        free(C->stack);
        C->cap = top-&dummy;
        C->stack = malloc(C->cap);
    }
    C->size = top - &dummy;
    memcpy(C->stack, &dummy, C->size);
}

上面這個最精彩就是_save_stack里面那個char dummy,這個變量有毛線用呢?
如果之前記得我們的例子,你應該知道這個dummy我們關心不是他的值(這里等于1 、2、3....100 whatever 不重要),重要的是這個dummy的地址,這個地址在哪兒呀? 在棧上,而別忘了棧是朝地址小的方向增長的!!!那么top一定比dummy的地址大(因為棧是朝地址小的方向增長的),那么代碼中top-&dummy對應一定是啥呀?那一定是這個協程執行過程中產生的棧信息(肯定不能丟呀 丟了這個協程再次回過頭來懵逼了),所以下面利用memcpy函數將&dummy地址拷貝數據到C->stack里面(拷貝大小自然是top- &dummy 看得出這里才對協程中的char *stack進行內存分配 用時拷貝 一開始你也不知道要分配多少合適呀 同時這里也解釋了為啥struct coroutine里面的cap和size變量是ptrdiff_t的類型 因為這兩個字段都是靠指針做減法得到的)。這里的dummy變量真是天馬行空,想象力奇特的一種寫法,小弟敬佩!
既然調度器里面棧信息已經拷貝到協程結構里面啦,那其他協程執行時是不是可以隨意搞了,反正影響不到此協程了呀,真心是棒棒噠!

最后,看看作者在main函數里面給的例子吧:

main.c:
#include "coroutine.h"
#include <stdio.h>

struct args {
    int n;
};

static void
foo(struct schedule * S, void *ud) {
    struct args * arg = ud;
    int start = arg->n;
    int i;
    for (i=0;i<5;i++) {
        printf("coroutine %d : %d\n",coroutine_running(S) , start + i);
        coroutine_yield(S);
    }
}

static void
test(struct schedule *S) {
    struct args arg1 = { 0 };
    struct args arg2 = { 100 };

    int co1 = coroutine_new(S, foo, &arg1);
    int co2 = coroutine_new(S, foo, &arg2);
    printf("main start\n");
    while (coroutine_status(S,co1) && coroutine_status(S,co2)) {
        coroutine_resume(S,co1);
        coroutine_resume(S,co2);
    } 
    printf("main end\n");
}

int 
main() {
    struct schedule * S = coroutine_open();
    test(S);
    coroutine_close(S);
    
    return 0;
}

上圖中的foo函數相當于業務代碼,需要我們自己寫,這里循環一次讓出一次CPU,所以代碼執行結果如下:

main start
coroutine 0 : 0
coroutine 1 : 100
coroutine 0 : 1
coroutine 1 : 101
coroutine 0 : 2
coroutine 1 : 102
coroutine 0 : 3
coroutine 1 : 103
coroutine 0 : 4
coroutine 1 : 104
main end

有沒有一種多線程運行結果的既視感? 但是可以看出代碼中既沒有使用fork函數(多進程)也沒有使用pthread函數(多線程),操作系統壓根沒有感覺到你在切換(我們根本沒有進入內核態),我們在用戶態就實現了這種切換的feel。真心是棒棒噠!

還有幾個小函數沒有講到,因為那幾個真心太簡單了,有的一兩行,都是為上面的核心流程服務的,理解了核心流程,那幾個函數自然而然的搞懂。
當然這個版本畢竟比較簡單,還只能做到協程自己主動讓出CPU,但是這么雷鋒的協程畢竟很少,其實更多時候是碰到阻塞操作了(比如IO操作),需要讓出CPU,那就是要涉及IO多路復用啦,云風大神只是給我們打個樣,讓我們理解這個版本之后往更復雜的版本邁進!

先寫到此吧,繼續擼libtask和libgo的代碼啦!有感再撰文!
I love coroutine!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容

  • Lua 5.1 參考手冊 by Roberto Ierusalimschy, Luiz Henrique de F...
    蘇黎九歌閱讀 13,864評論 0 38
  • 本文主要是對自己學習協程并實現輕量級協程過程的一個記錄, 語言略顯啰嗦, 各位見諒. 水平有限, 如有疏漏, 歡迎...
    neilzwshen閱讀 4,791評論 1 11
  • 一. 人生三大問:我是誰,我從哪來,我到哪去? 1.1. 協程是什么 我們知道,在現代計算機的世界里,有進程,有線...
    cunfate閱讀 4,163評論 0 5
  • 本系列博客是本人的開發筆記。為了方便討論,本人新建了一個微信群(iOS技術討論群),想要加入的,請添加本人微信:z...
    kyson老師閱讀 6,726評論 4 51
  • 前兩天阿里巴巴開源了coobjc,沒幾天就已經2千多star了,我也看了看源碼,主要關注的是協程的實現,周末折騰了...
    小涼介閱讀 20,326評論 3 25