文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

C++ 从零实现协程调度框架

2024-11-29 18:33

关注

该项目我已于 github 开源,cbricks 是我基于 c++11 从零实现的基础工具开源库:https://github.com/xiaoxuxiansheng/cbricks.其中实现的内容包括但不仅限于 协程调度框架 workerpool、协程 coroutine/线程 thread、并发通信队列 channel、日志打印组件 logger等基本工具类,而 协程调度框架 workerpool 正是我今天我要向大家介绍的主题.这是我作为 c++ 初学者推进的首个开源项目,完全出于学习实践目的,难免存在水平不足以及重复造轮的问题,如有考虑不到位、不完善之处请多多包涵,也欢迎批评指正~

在开始正文前,致敬环节 必不可少. 在实现 cbricks 的编程过程中,在很大程度上学习借鉴了sylar-yin 老师的课程,在此特别感谢,也附上其开源项目传送门供大家参考使用:https://github.com/sylar-yin/sylar . 正因为有前辈们慷慨无私的倾囊分享,我的学习之路才得以更加平坦顺畅. 正是以上种种鼓励着我能有动力把技术分享以及这种开源精神继续传播下去.

1 基本概念

首先,需要大家一起理清楚有关协程的基本概念.

1.1 线程与协程

我们通常所熟知的最小调度单元称为线程(thread),亦指内核级线程,其创建、销毁、调度过程需要交由操作系统内核负责. 线程与进程可以是多对一关系,可以充分利用 CPU 多核的能力,提高程序运行的并发度。

而协程(coroutine) 又称为用户级线程,是在用户态视角下对线程概念的二次封装. 一方面,协程与线程关系为多对一,因此在逻辑意义上属于更细粒度的调度单元;另一方面,因为协程的创建、销毁、调度行为都在用户态中完成,而无需内核参与,因此协程是一个更加轻量化的概念. (对于内核来说,其最小调度单元始终为线程不变,至于用户态下对线程又作了怎样的逻辑拆分,对于内核而言是完全透明无感知的,因此无需介入)

线程与协程

1.2 coroutine 与 goroutine

因为我毕竟有着较长的 golang 开发使用经验,需要在探讨相关问题的时候是无法绕开对 golang 中对 goroutine 这一设计的对比与探讨的.

我们把常规的协程称为 coroutine. 而在 golang 语言层面天然支持一种优化版协程模型,称为 goroutine,并运转调度于 go 语言中知名的 gmp(goroutine-machine-processor) 架构之下.

gmp架构

有关 gmp 相关内容更细致的讲解,可以参见我之前分享的文章:golang gmp 原理

在经历了 cbricks workerpool 的开发实践后,我也对 gmp 架构下 groutine 相较于普通 coroutine 存在的优势有了一些更深刻的体会:

用户视角下的gmp并发

• 阻塞粒度适配:这一点非常重要. golang 为使用方屏蔽了线程的概念,所有并发操作都基于 goroutine 粒度完成,这不仅限于调度,也包括与之相应的一系列并发阻塞工具,例如 锁 mutex,通道 channel 等,都在语言层面天然支持 goroutine 粒度的被动阻塞(go_park)操作,与 gmp 体系完美适配;而这一点在 c++ 中则有所不同,如 锁 mutex、信号量 semaphore 等工具的最小阻塞粒度都是线程,这就会导致协程的优势遭到削弱,因为在一个 coroutine 中的阻塞行为最终会上升到 thread 粒度,并进而导致 thread 下其他 coroutine 也无法得到正常调度.

2 快速上手

做完基本概念铺垫后,下面我们开始介绍有关协程调度框架 cbricks workerpool 的具体实现内容.

2.1 使用方法

本章我们聚焦在如何快速上手使用 workerpool 这一问题. workerpool 类型声明于 ./pool/workerpool.h 头文件中,使用方通常只需关心其构造函数和两个公开方法:

// 命名空间 cbricks::pool
namespace cbricks{namespace pool{
// 协程调度池
classWorkerPool: base::Noncopyable{
public:
// 构造函数  threads——使用的线程个数. 默认为 8 个
WorkerPool(size_t threads =8);
// ...
public:


bool submit(task task, bool nonblock = false);

// 工作协程调度任务过程中,可以通过执行次方法主动让出线程的调度权 (仿 golang runtime.Goched 风格)
void sched();
}}

2.2 使用示例

下面是关于 workerpool 的具体使用示例,其中演示了如何完成一个 workerpool 的初始化,并通过 submit 方法向其中批量投递异步执行的任务,最后对执行结果进行验收:

#include 

#include "sync/sem.h"
#include "pool/workerpool.h"

void testWorkerPool();

int main(int argc, char** argv){
// 测试函数
testWorkerPool();
}

void testWorkerPool(){
// 协程调度框架类型别名定义
typedef cbricks::pool::WorkerPool workerPool;
// 信号量类型别名定义
typedef cbricks::sync::Semaphore semaphore;

// 初始化协程调度框架,设置并发的 threads 数量为 8
workerPool::ptr workerPoolPtr(new workerPool(8));

// 初始化一个原子计数器
    std::atomic cnt{0};
// 初始化一个信号量实例
    semaphore sem;

// 投递 10000 个异步任务到协程调度框架中,执行逻辑就是对 cnt 加 1
for(int i =0; i <10000; i++){
// 执行 submit 方法,将任务提交到协程调度框架中
        workerPoolPtr->submit([&cnt,&sem](){
            cnt++;
            sem.notify();
});
}

// 通过信号量等待 10000 个异步任务执行完成
for(int i =0; i <10000; i++){
        sem.wait();
}

// 输出 cnt 结果(预期结果为 10000)
    std::cout << cnt << std::endl;
}

3 架构设计

了解完使用方式后,随后就来揭晓其底层实现原理. 本着由总到分的学习指导纲领,本章我们从全局视角纵览 workerpool 的设计实现架构.

3.1 整体架构与核心概念

cbricks协程调度架构

workerpool 自下而上,由粗到细可以分为如下层级概念:

• 线程池 threadPool:workerpool 初始化时就启动指定数量的常驻线程 thread 实例. 这些 thread 数量固定不变,并且会持续运行,直到整个 workerpool 被析构为止. 由这些 thread 组成的集合,我们称为 线程池 threadPool.

• 线程 thread:持续运营的 thread 单元,不断执行着调度逻辑,依次尝试从本地任务队列 taskq、本地协程队列 sched_q 中获取任务 task /协程 coroutine 进行调度. 如果前两者都空闲,则 thread 会仿照 gmp 中的 workstealing 机制,从其他 thread 的 taskq 中窃取 task 过来执行. 最后 steal 后仍缺少 task 供执行调度,则会利用 channel 的机制,使 thread 陷入阻塞,从而让出 cpu 执行权

• 任务 task:用户提交的异步任务(对应为 void() 闭包函数类型). task 会被均匀分配到特定 thread 的 taskq 中,但还存在被其他 thread 窃取的可能性,因此 task 本质上还是能够跨 thread 传递使用的

• 协程 coroutine:在 workerpool 中,thread 不会直接执行 task,而是会为 task 一对一构建出 coroutine 实例,并切换至 coroutine 中完成对 task 的执行. coroutine 被创建出来后,会完成栈 stack 的初始化和分配,随后 coroutine 就固定属于一个 thread 了,终生不可再被其他 thread 染指

• 线程本地任务队列 taskq:每个 thread 私有的缓存 task 的队列,底层由并发安全的通信队列 channel 实现. 当一笔 task 被投递到 workerpool 时,会基于负载均衡策略投递到特定 thread 的 taskq 中,接下来会被该 thread 优先调度执行

• 线程本地协程队列 schedq:每个 thread 私有的缓存 coroutine 的队列,底层由普通队列 queue 实现,但属于线程本地变量 thread_local,因此也是并发安全的. 当一个 coroutine 因主动让渡 sched 操作而暂停执行时,会将其暂存到 schedq 中,等待后续再找时机完成该 coroutine 的调度工作.

3.2 相比 gmp 的不足之处

我在实现 workerpool 时,一定程度上仿照了 gmp 的风格,包括 thread 本地任务队列 taskq 的实现以及 workstealing 机制的设计.

cbricks协程调度框架的不足之处

然而受限于我的个人水平以及语言层面的风格差异,相较于 gmp,workerpool 还存在几个明显的缺陷:

• coroutine 与 thread 强绑定:当一个 coroutine 被初始化时,我使用的是 c 语言中 ucontext.h 完成 stack 的分配,这样 coroutine stack 就是 thread 私有的,因此 coroutine 不能做到跨 thread 调度.

• thread 级阻塞粒度:c++ 中,并发工具因此的阻塞行为都是以 thread 为单位. 以互斥锁 lock 为例,哪怕触发加锁阻塞行为的对象是 coroutine,但最终还是会引起整个 thread 对象陷入阻塞,从而导致 thread 下的其他已分配好的 coroutine 也无法得到执行.

要解决这一问题,就必须连带着对 lock、cond、semaphore 等工具进行改造,使得其能够支持 coroutine 粒度的阻塞操作,这样的成本无疑很高,本项目未予以实践.

4 头文件源码

从第 4 章开始,我们正式进入源码解析环节. 首先给出关于 workerpool 头文件的完整代码展示,包含其中的成员属性以及公私方法定义. 下面的示意图以及源码中给出的注释相对比较完备,在此不再赘述:

workerpool 类定义

代码位于 ./pool/workerpool.h:

// 保证头文件内容不被重复编译
#pragma once 


// 标准库智能指针相关
#include 
// 标准库函数编程相关
#include 
// 标准库原子量相关
#include 
// 标准库——动态数组,以此作为线程池的载体
#include 


// 线程 thread 实现
#include "../sync/thread.h"
// 协程 coroutine 实现
#include "../sync/coroutine.h"
// 阻塞队列 channel 实现 (一定程度上仿 golang channel 风格)
#include "../sync/channel.h"
// 信号量 semaphore 实现
#include "../sync/sem.h"
// 拷贝禁用工具,用于保证类实例无法被值拷贝和值传递
#include "../base/nocopy.h"

// 命名空间 cbricks::pool
namespace cbricks{namespace pool{
// 协程调度池 继承 Noncopyable 保证禁用值拷贝和值传递功能
classWorkerPool: base::Noncopyable{
public:
// 协程池共享指针类型别名
typedef std::shared_ptr ptr;
// 一笔需要执行的任务
typedef std::function task;
// 一个线程持有的本地任务队列
typedef sync::Channel localq;
// 本地任务队列指针别名
typedef localq::ptr localqPtr;
// 线程指针别名
typedef sync::Thread* threadPtr;
// 一个分配了运行任务的协程
typedef sync::Coroutine worker;
// 协程智能指针别名
typedef sync::Coroutine::ptr workerPtr;
// 读写锁别名
typedef sync::RWLock rwlock;
// 信号量类型别名
typedef sync::Semaphore semaphore;

public:

// 构造函数  threads——使用的线程个数. 默认为 8 个
WorkerPool(size_t threads =8);
// 析构函数  
~WorkerPool();

public:


bool submit(task task, bool nonblock = false);

// 工作协程调度任务过程中,可以通过执行次方法主动让出线程的调度权 (仿 golang runtime.Goched 风格)
void sched();

private:

structthread{
typedef std::shared_ptr ptr;
int index;
        threadPtr thr;
        localqPtr taskq;
        rwlock lock;

thread(int index,threadPtr thr, localqPtr taskq):index(index),thr(thr),taskq(taskq){}
~thread()=default;
};

private:

// work:线程运行主函数,持续不断地从本地任务队列 taskq 或本地协程队列 t_schedq 中获取任务/协程进行调度. 倘若本地任务为空,会尝试从其他线程本地任务队列窃取任务执行
void work();

bool readAndGo(localqPtr taskq, bool nonblock);

void goTask(task cb);

void goWorker(workerPtr worker);


void workStealing();

void workStealing(thread::ptr stealTo, thread::ptr stealFrom);

thread::ptr getStealingTarget();


thread::ptr getThreadByThreadName(std::string threadName);

thread::ptr getThread();

private:

// getThreadNameByIndex:通过线程 index 映射得到线程名称
static const std::string getThreadNameByIndex(int index);
// getThreadIndex:获取当前线程的 index
static const int getThreadIndex();
// getThreadName:获取当前线程的名称
static const std::string getThreadName();


private:

// 基于 vector 实现的线程池,元素类型为 WorkerPool::thread 对应共享指针
    std::vector m_threadPool;

// 基于原子变量标识 workerPool 是否已关闭
    std::atomic m_closed{false};
};

}}

5 核心实现源码

接下来针对 workerpool 中的核心流程进行详细的源码走读,有关 workerpool 具体实现代码位于 ./pool/workerpool.cpp 中.

5.1 依赖的头文件与变量

图片

依赖的外部变量

首先涉及到两个核心变量的定义:

// 标准库队列实现. 依赖队列作为线程本地协程队列的存储载体
#include 

// workerpool 头文件
#include "workerpool.h"
// 本项目定义的断言头文件
#include "../trace/assert.h"

// namespace cbricks::pool
namespace cbricks{namespace pool{


static std::atomic s_taskId{0};


staticthread_local std::queue t_schedq;

// ...

}}

5.2 构造函数与析构函数

workerpool 构造函数

下面介绍workerpool 的构造函数,其任务很明确,就是初始化好指定数量的 thread,为其分配好对应的 taskq,并将 thread 一一投递进入到线程池 threadPool 中.

此处值得一提的是,thread 启动后异步运行的方法是 WorkerPool::work,其中会涉及到从 threadPool 中取出当前 thread 实例的操作,因此这里需要通过信号量 semaphore 保证 thread 实例先被投递进入 threadPool 后,对应 WorkerPool::work 方法才能被放行.

// namespace cbricks::pool
namespace cbricks{namespace pool{
// ...

WorkerPool::WorkerPool(size_t threads){
CBRICKS_ASSERT(threads >0,"worker pool init with nonpositive threads num");

// 为线程池预留好对应的容量
this->m_threadPool.reserve(threads);


std::vector sems(threads);
// 另一个信号量,用于保证所有 thread 调度函数都正常启动后,当前构造函数才能退出,避免 sems 被提前析构
    semaphore waitGroup;

// 初始化好对应数量的 thread 实例并添加进入 m_threadPool
for(int i =0; i < threads; i++){
// 根据 index 映射得到 thread 名称
        std::string threadName =WorkerPool::getThreadNameByIndex(i);
// 将 thread 实例添加进入 m_threadPool
this->m_threadPool.push_back(thread::ptr(
// thread 实例初始化
newthread(
            i,
// 
new sync::Thread([this,&sems,&waitGroup](){

                sems[getThreadIndex()].wait();

                waitGroup.notify();
// 异步启动的 thread,最终运行的调度函数是 workerpool::work
this->work();

},
// 注入 thread 名称,与 index 有映射关系
            threadName),
// 分配给 thread 的本地任务队列
localqPtr(new localq))));

        sems[i].notify();
}


for(int i =0; i < threads; i++){
        waitGroup.wait();
}
}

在析构函数中,要做的处理是将 workerpool 关闭标识 m_closed 置为 true,并且一一关闭所有 thread 下的 taskq ,这样运行中的 thread 在感知到这一信息后都会主动退出.

// 析构函数
WorkerPool::~WorkerPool(){
// 将 workpool 的关闭标识置为 true,后续运行中的线程感知到此标识后会主动退出
this->m_closed.store(true);
// 等待所有线程都退出后,再退出 workpool 的析构函数
for(int i =0; i m_threadPool.size(); i++){
// 关闭各 thread 的本地任务队列
this->m_threadPool[i]->taskq->close();
// 等待各 thread 退出
this->m_threadPool[i]->thr->join();
}
}

// ...

}}

5.3 公有方法:提交任务

workerpool提交任务流程

用户通过 submit 方法,能够将 task 提交到 workerpool 中. 在 submit 流程中:

这里需要注意的是,在投递任务到 thread 的 taskq 前,需要先加上该 thread 的读锁 readlock. 这是为了和该 thread 下可能正在执行的 workStealing 操作进行互斥,避免因 taskq 空间不足而导致死锁问题. 这个点在窃取流程的讲解中详细展开.

// namespace cbricks::pool
namespace cbricks{namespace pool{
// ...

bool WorkerPool::submit(task task, bool nonblock){
// 若 workerpool 已关闭,则提交失败
if(this->m_closed.load()){
returnfalse;
}

// 基于任务 id 对 m_threadPool 长度取模,将任务映射到指定 thread
int targetThreadId =(s_taskId++)%(this->m_threadPool.size());
    thread::ptr targetThr =this->m_threadPool[targetThreadId];

// 针对目标 thread 加读锁,这是为了防止和目标 thread 的 workstealing 操作并发最终因任务队列 taskq 容量溢出而导致死锁
rwlock::readLockGuard guard(targetThr->lock);

// 往对应 thread 的本地任务队列中写入任务
return targetThr->taskq->write(task, nonblock);
}

// ...

}}

5.4 公有方法:让渡执行权

workerpool协程让渡流程

task 在运行过程中,可以通过调用 workerpool::sched 方法完成执行权的主动让渡. 此时 task 对应 coroutine 会暂停运行,并将执行权切换回到 thread 主函数中,然后 thread 会将该 coroutine 暂存到本地协程队列 schedq 中,等待后续再对其调度执行.

// namespace cbricks::pool
namespace cbricks{ namespace pool{
// ...
// sched:让渡函数. 在任务执行过程中,可以通过该方法主动让出线程的执行权,则此时任务所属的协程会被添加到 thread 的本地协程队列 t_schedq 中,等待后续再被调度执行
void WorkerPool::sched(){
    worker::GetThis()->sched();
}

// ...

}}

5.5 线程调度任务主流程

workerpool线程调度主流程

workerpool::work 方法是各 thread 循环运行的主函数,其中包含了 thread 调度 task 和 coroutine 的核心逻辑:

// namespace cbricks::pool
namespace cbricks{namespace pool{
// ...

void WorkerPool::work(){
// 获取到当前 thread 对应的本地任务队列 taskq
    localqPtr taskq =this->getThread()->taskq;

// main loop
while(true){
// 如果 workerpool 已关闭 则主动退出
if(this->m_closed.load()){
return;
}



// 标识本地任务队列 taskq 是否为空
bool taskqEmpty =false;
// 至多调度 10 次本地任务队列 taskq
for(int i =0; i <10; i++){
// 以【非阻塞模式】从 taskq 获取任务并为之分配协程实例和调度执行
if(!this->readAndGo(taskq,false)){
// 如果 taskq 为空,将 taskqEmpty 置为 true 并直接退出循环
                taskqEmpty =true;
break;
}
}

// 尝试从线程本地的协程队列 t_schedq 中获取协程并进行调度
if(!t_schedq.empty()){
// 从协程队列中取出头部的协程实例
            workerPtr worker = t_schedq.front();
            t_schedq.pop();
// 进行协程调度
this->goWorker(worker);
// 处理完成后直接进入下一轮循环
continue;
}

// 如果未发现 taskq 为空,则无需 workstealing,直接进入下一轮循环
if(!taskqEmpty){
continue;
}


this->workStealing();


this->readAndGo(taskq,true);
}
}
// ...

}}

workerpool单个任务处理流程

以 readAndGo 方法为入口,thread 会尝试从 taskq 中获取一笔 task;获取到后,会为 task 构建一一对应的 coroutine 实例(至此 task/coroutine 与 thread 完全绑定),然后通过 coroutine::go 方法,将 thread 执行权切换至 coroutine 手中,由 coroutine 执行其中的 task. 只有在 task 执行结束或者主动让渡时,执行权才会返还到 thread 主函数中,此时 thread 会判断 coroutine 是否是因为主动让渡而暂停执行,如果是的话,则会将该 coroutine 实例追加到 schedq 中,等待后续寻找合适时机再作调度执行.

// namespace cbricks::pool
namespace cbricks{namespace pool{
// ...

// 将一个任务包装成协程并进行调度. 如果没有一次性调度完成,则将协程实例添加到线程本地的协程队列 t_schedq
bool WorkerPool::readAndGo(cbricks::pool::WorkerPool::localqPtr taskq, bool nonblock){
// 任务容器
    task cb;
// 从 taskq 中获取任务
if(!taskq->read(cb,nonblock)){
returnfalse;
}

// 对任务进行调度
this->goTask(cb);
returntrue;
}


void WorkerPool::goTask(task cb){
// 初始化协程实例
    workerPtr _worker(newworker(cb));
// 调度协程
this->goWorker(_worker);
}


void WorkerPool::goWorker(workerPtr worker){
// 调度协程,此时线程的执行权会切换进入到协程对应的方法栈中
    worker->go();
// 走到此处意味着线程执行权已经从协程切换回来
// 如果此时协程并非已完成的状态,则需要将其添加到线程本地的协程队列 schedq 中,等待后续继续调度
if(worker->getState()!= sync::Coroutine::Dead){
        t_schedq.push(worker);
}
}
// ...

}}

5.6 任务窃取流程

workerpool跨线程任务窃取流程

当 thread 发现 taskq 和 schedq 都空闲时,则会尝试执行窃取操作. 此时 thread 随机选取另一个 thread 作为窃取目标,窃取其 taskq 中的半数 task,追加到本地 taskq 中.

在执行窃取操作的过程中,需要对当前 thread 加写锁,以避免发生死锁问题:

比如在窃取前,当前 thread 判定自己的 taskq 还有足够空间用于承载窃取来的 task;但是此期间若有新的任务 submit 到来,则可能把 taskq 的空间占据,最后导致没有足够容量承载窃取到的 task,最终导致 thread 调度流程 hang 死在 workstealing 流程无法退出.

上述问题的解法就是,在窃取前,先加 thread 写锁(这样并发到来的 submit 操作就无法完成 task 投递)然后再检查一遍 taskq 并确认容量充足后,再发起实际的窃取操作.

// namespace cbricks::pool
namespace cbricks{namespace pool{
// ...
// 从某个 thread 中窃取一半任务给到本 thread 的 taskq
void WorkerPool::workStealing(){
// 选择一个窃取的目标 thread 
    thread::ptr stealFrom =this->getStealingTarget();
if(!stealFrom){
return;
}
// 从目标 thread 中窃取半数任务添加到本 thread taskq 中
this->workStealing(this->getThread(),stealFrom);
}

// 从 thread:stealFrom 中窃取半数任务给到 thread:stealTo
void WorkerPool::workStealing(thread::ptr stealTo, thread::ptr stealFrom){
// 确定窃取任务数量:目标本地任务队列 taskq 中任务总数的一半
int stealNum = stealFrom->taskq->size()/2;
if(stealNum <=0){
return;
}

// 针对 thread:stealTo 加写锁,防止因 workstealing 和 submit 行为并发,导致线程因 taskq 容量溢出而发生死锁
rwlock::lockGuard guard(stealTo->lock);
// 检查此时 stealTo 中的 taskq 如果剩余容量已不足以承载拟窃取的任务量,则直接退出
if(stealTo->taskq->size()+ stealNum > stealTo->taskq->cap()){
return;
}

// 创建任务容器,以非阻塞模式从 stealFrom 的 taskq 中窃取指定数量的任务
std::vector containers(stealNum);
if(!stealFrom->taskq->readN(containers,true)){
return;
}

// 将窃取到的任务添加到 stealTo 的 taskq 中
    stealTo->taskq->writeN(containers,false);
}

// 随机选择本 thread 外的一个 thread 作为窃取的目标
WorkerPool::thread::ptr WorkerPool::getStealingTarget(){
// 如果线程池长度不足 2,直接返回
if(this->m_threadPool.size()<2){
returnnullptr;
}

// 通过随机数,获取本 thread 之外的一个目标 thread index
int threadIndex =WorkerPool::getThreadIndex();
int targetIndex =rand()%this->m_threadPool.size();
while( targetIndex == threadIndex){
        targetIndex =rand()%this->m_threadPool.size();
}

// 返回目标 thread
returnthis->m_threadPool[targetIndex];
}
// ...

}}

6 总结

祝贺,至此本文结束. 本篇和大家探讨了,如何基于 c++ 从零到一实现一个协程调度框架,其核心功能包括:

来源:小徐先生的编程世界内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯