从零开始手写一个协程库(一)

引言

从本篇文章开始,我会逐一介绍协程库的一些重要代码和知识点~~~

什么是协程库?简单来说就是一个超级轻量级的线程,这个线程还是用户态线程。那么我们为什么要实现协程库呢?在高并发的环境下,如果因为一个任务的阻塞而无法执行其他任务,会导致整个系统的死机。而协程就帮助了这个点,可以在阻塞的时候去执行其他的任务

resume 恢复

yeild 暂停

API的简介

对于项目里面的四个主要的API进行介绍一下:(重点

getcontext(ucontext_t *ucp):这个是获取当前的上下文,放到ucp中,具体就是直到CPU的状态,sp指针,寄存器等等

setcontext(const ucontext_t *ucp):这个是设置当前的上下文,把ucp指向的地址里面的内容取出来,设置为当前要执行的代码,和上面那个不同的是,我们要执行代码,必须要把上下文交给CPU,让CPU去执行你的代码。当然,这个上下文也就是这个ucp来源有两个,一个是getcontext(),那就继续执行这个调用,相当于再执行一遍之前的代码;还有一个是makecontext(),这个我们会先执行第二个参数,如果第二个参数返回,则恢复makecontext第一个参数指向的上下文第一个参数指向的上下文context_t中指向的uc_link。如果uc_link为NULL,则线程退出

makecontext((ucontext_t *ucp, void (*func)(), int argc, ...):这个是创建一个新的上下文,而创建一个上下文需要的是当前的环境(一些信号量啥的),所以我们的第一个参数就是传递一个原来的上下文指针,这个样子我们就不需要关心环境问题了,所以我们必须要提前调用getcontext(),调用之后因为这是一个新的上下文,所以要新的栈,所以我们需要新的空间去存储这个栈,规定栈的大小,还要交代uc_link,也就是这个函数结束之后,我们应该跳转到哪个函数,而这一切都是在getcontext()之后,因为如果在之前,那么CPU会记录下我们现在使用的栈和SP指针,可是我们现在运行的代码还是原来的上下文,也就是说我们直接把我们辛辛苦苦改的新值用旧值覆盖了。但是这个函数不是执行函数,这个只是创建了一个上下文

swapcontext(ucontext_t *oucp, ucontext_t *ucp):这是一个执行函数,保存当前上下文到oucp结构体中,然后激活upc上下文

cpp 复制代码
#include <ucontext.h>
#include <stdio.h>

void func1(void * arg)
{
    puts("1");
    puts("11");
    puts("111");
    puts("1111");

}
void context_test()
{
    char stack[1024*128];
    ucontext_t child,main;

    getcontext(&child); //获取当前上下文
    child.uc_stack.ss_sp = stack;//指定栈空间
    child.uc_stack.ss_size = sizeof(stack);//指定栈空间大小
    child.uc_stack.ss_flags = 0;
    child.uc_link = &main;//设置后继上下文

    makecontext(&child,(void (*)(void))func1,0);//修改上下文指向func1函数

    swapcontext(&main,&child);//切换到child上下文,保存当前上下文到main
    puts("main");//如果设置了后继上下文,func1函数指向完后会返回此处
}

int main()
{
    context_test();

    return 0;
}

为了大家更加方便的理解,我们这里还抛出一个小问题:

Hello World这里是不可以被打印出来的,原因就是makecontext()已经修改了上下文,导致setcontext()调用完之后,发现下一个指向的函数被设定为NULL,那么不会返回到之前的地方继续执行了

cpp 复制代码
#include <stdio.h>
#include <ucontext.h>

void func1() {
    puts("In func1");
}

int main() {
    ucontext_t context;
    getcontext(&context);
    context.uc_stack.ss_sp = malloc(8192);
    context.uc_stack.ss_size = 8192;
    context.uc_link = NULL;
    makecontext(&context, func1, 0);
    setcontext(&context);
    puts("Hello World");
    return 0;
}

前置知识

cpp 复制代码
class Fiber : public std::enable_shared_from_this<Fiber>

这个代码是让Fiber这个类继承了public std::enable_shared_from_this 大类,其内部的主要就是给这个fiber类塞进去一个weak_ptr指针,这个指针当这个类被调用shared_from_this()函数的时候,让weak_ptr执行lock(),就会让其引用计数+1。不要小看这个+1。我们举一个简单的例子,一个协程在运行的过程中执行了一个读取文件的操作,这个读取文件的操作要很长时间,所以我们会交给另外一个函数去执行,而当前的函数则继续向下执行,假设很快就结束了,生命周期结束,那么这个时候就会将引用-1,如果没有这个+1,那么引用计数变为0,指针消失。等到读取文件的操作结束,回调这个函数指针的时候,发现这个函数指针已经被销毁,直接抛出异常。而我们的+1就保证了这个错误不会发生,只有当整个程序完全的结束我们才会销毁这个指针。

而在我们刚刚的一个例子里面,就涉及到了另一个理解的点,就是一个协程的函数里面套了另外一个函数,这两个函数是没有任何关系的,也就是说一个函数的结束不会影响另外一个函数。

cpp 复制代码
class Fiber : public std::enable_shared_from_this<Fiber> {
public:
    void doSomethingAsync() {
        // 假设这里要发起一个异步操作,需要把"我自己"传给回调函数
        // 如果直接传 this 指针,异步操作完成时,这个 Fiber 对象可能已经被销毁了!
        
        // 正确做法:在内部安全地获取自己的 shared_ptr,延长生命周期
        auto self = shared_from_this(); 
        
        asyncCall([self]() {
            // 只要 self 还在,Fiber 对象就绝对不会被释放
            self->onComplete(); 
        });
    }
};

Thread类的实现

协程是运行在线程里面的,所以线程线程就是协程运行的环境

我们主要有两个类:一个是信号量,这个信号量就是为了保证创建线程的时候资源不会发生冲突;一个是线程,这个线程里面的主要函数一个是构造函数,一个是run()函数,

cpp 复制代码
#ifndef THREAD_H
#define THREAD_H

#include <condition_variable>
#include <mutex>
#include <functional>

namespace fengyue {
class Semaphore {
private:
    std::mutex mutex;
    std::condition_variable cv;
    int count;

public:
    explicit Semaphore(int count_ = 0) : count(count_) {}

    // p操作
    void wait() {
        std::unique_lock<std::mutex> lock(mutex);
        while (count == 0) {
            cv.wait(lock);
        }
        count--;
    }

    // v操作
    void signal() {
        std::unique_lock<std::mutex> lock(mutex);
        count++;
        cv.notify_one(); // 这里的one指的不止有一个线程,可能有多个线程
    }
};

/* 创建并管理底层线程,为协程提供运行环境,同时通过线程局部存储和同步机制,为协程调度提供必要条件 */
class Thread {
public: 
    Thread(std::function<void()> func, const std::string& name);
    ~Thread();

    pid_t getId() const {return m_id;} // 该Thread管理的线程的id
    const std::string& getName() const {return m_name;}

    void join(); // 等待线程执行完成

public: 
    static pid_t GetThread(); // 获取系统分配线程的id)(当前执行上下文的进程)
    static Thread* GetThis(); // 获取当前所在的进程
    static const std::string& GetName(); // 获取当前线程的名称
    static void SetName(const std::string& name); // 设置当前线程的名称

private:
    static void* run(void* arg);

private:
    pid_t m_id = -1;
    pthread_t m_thread = 0; 

    std::string m_name; // 线程名称
    std::function<void()> m_func; // 线程运行函数

    Semaphore m_semaphore; // 引入信号量的类来完成线程的同步创建操作
};

}
#endif

首先我们用thread_local将每一个线程作为副本隔离开,防止互相的干扰,然后设置了每一个线程对应的指针。

构造函数我们需要在创建线程的时候传递这个线程要执行的函数和函数的参数,这里我们有两个细节,一个是我们run()使用的是静态函数,因为pthread_create只接受c风格的参数,而c++的类函数里面自己包含了隐式的this指针,导致函数不符合。所以我们用static消除this指针,变成c风格的函数,然后同时将this指针作为参数传递进去,用void* arg来接受,接受之后再一顿赋值,保证执行这个函数的时候环境正确,然后拿到我们创建线程时候传参构造的函数,通过swap()这么一个高效的赋值方式来把函数传递给func(如果m_func很大,那么就要拷贝很多东西,效率极低,我们这里选择浅拷贝)

最后我们看一下信号量是怎么运用的?首先在创建线程的函数里面调用wait,但是因为初始值是0,所以这个线程睡着了,然后创建的线程当创建完毕之后会发出信号,唤醒这个主线程,这个主线程才可以继续往下执行。这完美的避免了同时创建很多个要执行的线程。

cpp 复制代码
#include "Thread.h"

#include <sys/syscall.h>
#include <iostream>
#include <unistd.h>

namespace fengyue {

    /*
    static表示变量的生命周期在生命周期结束才会被销毁
    thread_local表示变量是线程局部的,每个线程都会拥有一个独立的Thread指针和当前对象的名称,多个线程互相不干扰
    */
    static thread_local Thread* t_thread = nullptr; // 当前线程的Thread对象指针
    static thread_local std::string t_thread_name = "UNKNOWN"; // 当前线程的名称

    pid_t Thread::GetThread() {
        return syscall(SYS_gettid); // 一个系统调用,用于获取当前线程的唯一ID。SYS_gettid 是Linux特定的系统调用编号
    }
    
    
    void* Thread::run(void* arg)
    {
        Thread* thread = (Thread*)arg;

        t_thread = thread;
        t_thread_name = thread->m_name;
        thread->m_id = GetThread();
        /*
        pthread_self()返回当前线程的ID,用于设置线程名称
        给当前线程在操作系统层面起一个专属的名字
        只截取15个字节是因为Linux严格限制了名称长度最多只能有 16 个字节
        */
        pthread_setname_np(pthread_self(), thread->m_name.substr(0, 15).c_str());
        
        std::function<void()> func;
        func.swap(thread->m_func); 
        thread->m_semaphore.signal();

        func();
        return nullptr;
    } 

    Thread::Thread(std::function<void()> func, const std::string& name) : m_func(func), m_name(name) {
        int ret = pthread_create(&m_thread, nullptr, &Thread::run, this);
        if (ret) {
            std::perror("pthread_create error");
        }
        m_semaphore.wait(); // 等待线程创建完成
    }
        
}

总结

本篇文章到这里就结束了!!!希望可以帮助大家理解协程库~~~