hio_write

不聞窗外事發表於2020-12-27

之前的部落格已經提到傳送資料要比接收資料難,因為傳送資料是主動的,接收資料是被動的。而且因為libhv採用的是level trigger,因此只有在需要時才關注寫事件,否則就會造成busy loop。原因可以參考上一篇部落格。

int hio_write (hio_t* io, const void* buf, size_t len) {
    //判斷io是否處於關閉狀態
    if (io->closed) {
        hloge("hio_write called but fd[%d] already closed!", io->fd);
        return -1;
    }
    int nwrite = 0;
    //判斷寫佇列是否為空,如果不為空,不能直接寫,要先處理寫佇列中的資料,否則會照成資料亂序
    //所以當佇列中有資料時,直接將本次的資料加入到佇列尾
    if (write_queue_empty(&io->write_queue)) {
try_write:
        //傳送資料
        nwrite = __nio_write(io, buf, len);
        //printd("write retval=%d\n", nwrite);
        if (nwrite < 0) {
            //如果是EAGAIN,那麼需要之後再嘗試傳送,所以這裡先入佇列
            if (socket_errno() == EAGAIN) {
                nwrite = 0;
                hlogw("try_write failed, enqueue!");
                goto enqueue;
            }
            else {
                // 出現錯誤,關閉連線
                io->error = socket_errno();
                goto write_error;
            }
        }
        if (nwrite == 0) {
            goto disconnect;
        }
        __write_cb(io, buf, nwrite);
        //如果一次性傳送完成,直接就返回,不需要使用寫佇列
        if (nwrite == len) {
            //goto write_done;
            return nwrite;
        }
        
enqueue:
        //註冊寫事件
        hio_add(io, hio_handle_events, HV_WRITE);
    }
    //如果沒有一次性傳送完成,需要將資料加入寫佇列
    if (nwrite < len) {
        offset_buf_t rest;
        rest.len = len; //資料總長度
        rest.offset = nwrite; //偏移量
        // NOTE: free in nio_write
        HV_ALLOC(rest.base, rest.len);
        memcpy(rest.base, buf, rest.len);
        if (io->write_queue.maxsize == 0) {
            write_queue_init(&io->write_queue, 4);
        }
        //加入佇列尾
        write_queue_push_back(&io->write_queue, &rest);
    }
    return nwrite;
write_error:
disconnect:
    hio_close(io);
    return nwrite;
}

hio_write中有幾個比較重要的地方,第一個就是寫佇列,當寫資料比較多時,可能無法一次寫完,又因為是非阻塞套接字,所以在傳送緩衝區沒有空間時會直接返回(如果是阻塞套接字會一直阻塞等待,直到傳送完成),這時候即使再嘗試寫剩下的資料,很大概率會返回EAGAIN,之前就提到過,像libhv這樣的網路庫,應該只能阻塞在loop等待事件發生的介面(例如epoll_wait),而不能阻塞在read和write這些io處理介面,所以這裡不能等待寫完所有的資料,只能先將資料儲存到寫佇列中,並將寫事件新增到io事件監視器。在分析IO事件監視器對寫事件的處理時,先看下寫佇列,在前面的部落格巨集定義黑魔法中提到過一點佇列的實現。使用佇列,先進先出,保證傳送資料不會出現亂序。

相關的定義,佇列的細節可以參考程式碼,比較簡單

struct write_queue  write_queue;    // for hwrite

QUEUE_DECL(offset_buf_t, write_queue);

#define QUEUE_DECL(type, qtype) \
struct qtype {      \
    type*   ptr;    \
    size_t  size;   \
    size_t  maxsize;\
    size_t  _offset;\
};   


typedef struct offset_buf_s {
    char*   base;
    size_t  len;
    size_t  offset;
#ifdef __cplusplus
    offset_buf_s() {
        base = NULL;
        len = offset = 0;
    }

    offset_buf_s(void* data, size_t len) {
        this->base = (char*)data;
        this->len = len;
    }
#endif
} offset_buf_t;


write_queue就是一個內容為offset_buf_s的佇列,offset_buf_s是用來存放未寫完的資料的,也比較簡單。

hio_write中還有一個值得注意的地方是__write_cb,這個在前面的keepalive那篇部落格中提到過

static void __write_cb(hio_t* io, const void* buf, int writebytes) {
    // printd("< %.*s\n", writebytes, buf);
    if (io->keepalive_timer) {
        htimer_reset(io->keepalive_timer);
    }

    if (io->write_cb) {
        // printd("write_cb------\n");
        io->write_cb(io, buf, writebytes);
        // printd("write_cb======\n");
    }
}

在hio_write中會更新keepalive定時器,而且如果有寫回撥函式,會在這裡呼叫回撥函式,關於該回撥的使用後面分析nio_write時會繼續說明。

如上分析,當呼叫hio_write傳送資料量比較大,無法一次寫完成時,需要關注寫事件。hio_add(io, hio_handle_events, HV_WRITE)。根據上一篇部落格的分析,可以得知,等到有足夠的空間可以寫時,寫事件會觸發,當寫事件觸發時,hio_handle_events被呼叫,這個介面前面的部落格已經多次提到了,但每次關注的地方都不一樣。

static void hio_handle_events(hio_t* io) {
    if ((io->events & HV_READ) && (io->revents & HV_READ)) {
        if (io->accept) {
            nio_accept(io);
        }
        else {
            nio_read(io);
        }
    }

    if ((io->events & HV_WRITE) && (io->revents & HV_WRITE)) {
        // NOTE: del HV_WRITE, if write_queue empty
        if (write_queue_empty(&io->write_queue)) {
            iowatcher_del_event(io->loop, io->fd, HV_WRITE);
            io->events &= ~HV_WRITE;
        }
        if (io->connect) {
            // NOTE: connect just do once
            // ONESHOT
            io->connect = 0;

            nio_connect(io);
        }
        else {
            nio_write(io);
        }
    }

    io->revents = 0;
}

本次關注的是io的寫,nio_write(io);

static void nio_write(hio_t* io) {
    //printd("nio_write fd=%d\n", io->fd);
    int nwrite = 0;
write:
    //如果佇列為空,判斷是否需要關閉io
    if (write_queue_empty(&io->write_queue)) {
        if (io->close) {
            io->close = 0;
            hio_close(io);
        }
        return;
    }
    //從寫佇列中拿資料
    offset_buf_t* pbuf = write_queue_front(&io->write_queue);
    //找到還未寫的資料
    char* buf = pbuf->base + pbuf->offset;
    int len = pbuf->len - pbuf->offset;
    nwrite = __nio_write(io, buf, len);
    //printd("write retval=%d\n", nwrite);
    if (nwrite < 0) {
        if (socket_errno() == EAGAIN) {
            //goto write_done;
            return;
        }
        else {
            io->error = socket_errno();
            // perror("write");
            goto write_error;
        }
    }
    if (nwrite == 0) {
        goto disconnect;
    }
    __write_cb(io, buf, nwrite);
    pbuf->offset += nwrite;
    //傳送完成,從佇列中刪除
    if (nwrite == len) {
        HV_FREE(pbuf->base);
        write_queue_pop_front(&io->write_queue);
        // write next
        goto write;
    }
    return;
write_error:
disconnect:
    hio_close(io);
}

最開始判斷佇列是否為空,如果為空,會處理close標誌。這裡的原因我們可以看下hio_close的實現,下面的程式碼只包含相關的部分。

int hio_close (hio_t* io) {
    if (io->closed) return 0;
    if (!write_queue_empty(&io->write_queue) && io->error == 0 && io->close == 0) {
        io->close = 1;
        hlogw("write_queue not empty, close later.");
        int timeout_ms = io->close_timeout ? io->close_timeout : HIO_DEFAULT_CLOSE_TIMEOUT;
        io->close_timer = htimer_add(io->loop, __close_timeout_cb, timeout_ms, 1);
        io->close_timer->privdata = io;
        return 0;
    }

在hio_close中,會判斷寫佇列是否為空,如果不為空,只設定了close標誌,但實際上並沒有真正關閉io,這裡其實是在等待未寫完的資料繼續寫完。所以在nio_write中,佇列為空時,判斷close標誌,如果已經關閉了,在hio_write中再次呼叫hio_close關閉該io。當然,在hio_close中,設定了一個定時器,如果長時間未關閉,定時器觸發,呼叫__close_timeout_cb

static void __close_timeout_cb(htimer_t* timer) {
    hio_t* io = (hio_t*)timer->privdata;
    if (io) {
        char localaddrstr[SOCKADDR_STRLEN] = {0};
        char peeraddrstr[SOCKADDR_STRLEN] = {0};
        hlogw("close timeout [%s] <=> [%s]",
                SOCKADDR_STR(io->localaddr, localaddrstr),
                SOCKADDR_STR(io->peeraddr, peeraddrstr));
        io->error = ETIMEDOUT;
        hio_close(io);
    }
}

在該回撥中,設定了io的error,所以再次呼叫hio_close,強制關閉io。

再回到nio_write,這個介面就是從寫佇列中拿到未寫完的資料,然後繼續寫。在這個介面裡也呼叫了__write_cb(io, buf, nwrite)。因為在libhv中暫時沒發現使用寫回撥的例子,所以在這裡我使用在muduo中看到的一個用法,使用寫回撥控制傳送流量,在muduo中,該功能叫做WriteCompleteCallback,是在每次寫完成後呼叫的回撥函式。使用該回撥函式,可以有效的控制傳送方傳送資料的速度。例如,一個伺服器要向一個客戶端持續傳送一些資料,可以在傳送完一條資料後,也就是在WriteCompleteCallback中繼續傳送下一條資料,避免出現傳送方傳送資料的速度高於對方接收資料的速度,造成本地記憶體堆積。

當資料傳送完成後,需要從io事件監視器中刪除對寫事件關注,該功能也是在上面的hio_handle_events中實現的

        if (write_queue_empty(&io->write_queue)) {
            iowatcher_del_event(io->loop, io->fd, HV_WRITE);
            io->events &= ~HV_WRITE;
        }

等寫完成後,由於沒有刪除對寫事件的關注,所以寫事件會繼續觸發(原因參考上一篇部落格),在hio_handle_events回撥中判斷寫佇列為空,刪除對寫事件的關注。

ok,暫時先分析這麼多了。。。