Win32下的非侵入式协程实现

关于协程和libco
本项目Github地址
  协程实现异步其实就是用同步的业务逻辑代码,但内部却执行异步等待并进行调度,既保证的代码的可读性,又能实现异步的高并发。在Windows编程中,往往会有大量阻塞的IO操作,比如这段代码:

    CHAR buf[4096];
    DWORD Size;
    LONG Offset = 0;
    SIZE_T CSize;

    PVOID CompressData;

    HANDLE g = CreateFile(L"F:\\test.iso", GENERIC_ALL, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, 0, NULL);

    for (int i = 0;i < COUNT;i++) {
        SetFilePointer(g, 4096 * i, &Offset, FILE_BEGIN);
        ReadFile(g, buf, 4096, &Size, NULL);

        COMPRESSOR_HANDLE Com = NULL;
        CompressData = malloc(4096);
        CreateCompressor(COMPRESS_ALGORITHM_LZMS, NULL, &Com);
        if (Com != NULL) {
            Compress(Com, buf, 4096, CompressData, 4096, &CSize);
            CloseCompressor(Com);
        }
        free(CompressData);
    }

    CloseHandle(g);

  这段代码中,会从文件读取一段固定4096字节的数据,并将其压缩。程序在执行时,因为ReadFile是非阻塞IO,压缩前不得不等待ReadFile完成数据的读取,而这段时间就会导致CPU资源的浪费,如果能一边读取文件,一边对已经读取完的文件内容进行压缩,这就会大大提高CPU的利用率。
  后来发明了异步IO,在Windows上的体现就是IO完成端口,在执行IO操作时,会先像内核注册一个IO监听,由内核负责监听IO的完成并将其挂在IO完成端口的完成队列里,这样应用层可以在这时去做其他的事情,等IO完成了,应用层在轮询IO完成队列时就能知晓并执行后序的操作。但这种机制在代码上可读性很差,IO完成的操作和IO发起的操作不在同一个函数中。
  于是现在又重新拾起了协程,在这篇文章中会介绍实现一种非侵入式的协程IO,看看如何在不改变原有同步业务逻辑的情况下实现异步IO。
  首先需要HOOK掉IO函数为自己自定义的函数,这样在用户看来调用的仍然是原来的同步IO函数,而我则会在函数内将其修改为异步IO。
  首先看自定义的函数的第一部分:

/**
 * 自定义的支持协程的ReadFile
 */
BOOL
WINAPI
Coroutine_ReadFile(
    _In_ HANDLE hFile,
    _Out_writes_bytes_to_opt_(nNumberOfBytesToRead, *lpNumberOfBytesRead) __out_data_source(FILE) LPVOID lpBuffer,
    _In_ DWORD nNumberOfBytesToRead,
    _Out_opt_ LPDWORD lpNumberOfBytesRead,
    _Inout_opt_ LPOVERLAPPED lpOverlapped
) {

    //判断是不是纤程
    if (!IsThreadAFiber()) {
        return System_ReadFile(hFile,
            lpBuffer,
            nNumberOfBytesToRead,
            lpNumberOfBytesRead,
            lpOverlapped
        );
    }

    ...
    //申请一个Overlapped的上下文
    PCOROUTINE_OVERLAPPED_WARPPER OverlappedWarpper = (PCOROUTINE_OVERLAPPED_WARPPER)malloc(sizeof(COROUTINE_OVERLAPPED_WARPPER));
    if (OverlappedWarpper == NULL) {
        *lpNumberOfBytesRead = 0;
        SetLastError(ERROR_NOT_ENOUGH_MEMORY);
        return FALSE;
    }
    memset(OverlappedWarpper, 0, sizeof(COROUTINE_OVERLAPPED_WARPPER));

    ...

    Succeed = System_ReadFile(hFile,
        lpBuffer,
        nNumberOfBytesToRead,
        lpNumberOfBytesRead,
        &OverlappedWarpper->Overlapped
    );
    if (Succeed || GetLastError() != ERROR_IO_PENDING) {
        goto EXIT;
    }

    //手动调度纤程
    CoSyncExecute(FALSE);

    ...

  当用户调用ReadFile时,会跳转到Coroutine_ReadFile函数中,这个函数保留其他的参数,但额外添加了一个Overlapped参数,这样这个ReadFile就从同步转为异步了。在最后,调用了CoSyncExecute来对纤程进行调度。其实这个函数会跳转到实现调度的纤程中去调度其他处于等待中的纤程。当这个IO完成时,调度算法会最终调度回该函数的上下文中,纤程就会从CoSyncExecute调用后面继续执行第二部分:

    SetLastError(OverlappedWarpper->ErrorCode);
    if (OverlappedWarpper->ErrorCode != ERROR_SUCCESS) {
        goto EXIT;
    }

    Succeed = TRUE;

EXIT:
    *lpNumberOfBytesRead = OverlappedWarpper->BytesTransfered;
    free(OverlappedWarpper);

    return Succeed;

  在第二部分,获取并设置了错误码,然后返回完成的IO的字节数并返回。这样,在调用ReadFile的代码逻辑看来,这个ReadFile仍然是阻塞IO,但在阻塞过程中,其实执行流已经跳转到其他的纤程中继续执行了,这样可以充分的利用CPU。
  然后可以看看调度协程是如何进行调度的(当然当前的调度方法并没有什么花样,很简单)。

/**
 * 调度协程
 */
VOID
WINAPI CoScheduleRoutine(
    LPVOID lpFiberParameter
) {

    ...

    //从TLS中获取协程实例
    PCOROUTINE_INSTANCE Instance = (PCOROUTINE_INSTANCE)TlsGetValue(0);

    //如果有完成的IO端口事件,优先继续执行
DEAL_COMPLETED_IO:
    while (GetQueuedCompletionStatus(Instance->Iocp, &ByteTransfered, &IoContext, &Overlapped, Timeout)) {

        PCOROUTINE_OVERLAPPED_WARPPER Context = (PCOROUTINE_OVERLAPPED_WARPPER)
            CONTAINING_RECORD(Overlapped, COROUTINE_OVERLAPPED_WARPPER, Overlapped);
        ...

        //这个结构可能在协程执行中被释放了
        Victim = Context->Fiber;
        SwitchToFiber(Victim);

        ...

    }

    //继续执行因为其他原因打断的协程或者新的协程
    if (!Instance->FiberList->empty()) {

        Victim = (PVOID)Instance->FiberList->front();
        Instance->FiberList->pop_front();
        SwitchToFiber(Victim);

        ...

        //如果有协程可执行,那么可能后面还有新的协程等待执行
        Timeout = 0;
    }
    else {

        //如果没有,那么就让完成端口等久一点
        Timeout = 500;
    }

    goto DEAL_COMPLETED_IO;
}

  其实很简单,首先判断有没有IO事件已经完成,如果有,直接调度到对应的纤程继续执行。否则的话,判断有没有普通的纤程,如果有,则调度到普通纤程。如果没有,很可能暂时都没有任何需要执行的了,就加大等待IO事件完成的超时时间。
  通过这种方式,原来的程序只需要添加初始化和插入业务逻辑的函数,无需改动业务代码就可以实现单线程的高并发。

阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页