• 使用 C++23 协程实现第一个 co_await 同步风格调用接口--Qt计算文件哈希值


    C++加入了协程 coroutine的特性,一直没有动手实现过。看了网上很多文章,已经了解了协程作为“可被中断和恢复的函数”的一系列特点。在学习过程中,我发现大多数网上的例子,要不就是在main()函数的控制台程序里演示yeild,await, resume的特性,要不就是讲述很多概念,很少有演示协程究竟如何把异步变成同步调用的。本次,我们就通过一个简单的计算文件哈希值的例子,来演示如何进行co_await协程操作。co_yeild放到下一篇

    1. 原始的哈希值计算

    假设存在一个最简单的哈希计算需求,要计算一个大文件的指纹。我们很容易实现一个演示算法:

    void DlgCT::on_pushButton_normal_clicked(){
    	QFile fp(filename);
    	char buf[1024];
    	unsigned long long hashfile = 0;
    	if (fp.open(QIODevice::ReadOnly))
    	{
    		int rlen = fp.read(buf,1024);
    		while (rlen>0)
    		{
    			for (int i=0;i<rlen;++i)
    			{
    				unsigned char c = hashfile>>56;
    				hashfile <<=8;
    				hashfile ^= (buf[i] ^ c );
    			}
    			rlen = fp.read(buf,1024);
    			//假多线程,也可以看做是Qt的有栈协程,人为释放资源
    			QCoreApplication::processEvents();
    		}
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    上面的代码,在文件比较大时,如果没有‘’QCoreApplication::processEvents();”显然会阻塞界面,导致按钮弹不起来,界面卡死。当然,可以通过适时调用QCoreApplication::processEvents();保持消息循环。这是一种假多线程,也可以看做是Qt的有栈协程,人为释放资源让给其他消息。

    2. 异步计算改造

    为了不阻塞主界面,传统上喜欢使用另一个线程来处理算法,并在完成后通知主线程。有一个处理类:

    class fileDealer : public QObject
    {
    	Q_OBJECT
    public:
    	explicit fileDealer(QObject *parent = nullptr);
    	//dealFile 计算哈希,存储在 result 里
    	void dealFile(QString filename);
    public:
    	QByteArray result;
    private:
    	std::thread * m_pThread = nullptr;
    signals:
    	void sig_done();
    };
    
    void fileDealer::dealFile(QString filename)
    {
    	m_pThread = new std::thread([filename,this]()->void{		
    		QFile fp(filename);
    		char buf[1024];
    		unsigned long long hashfile = 0;
    		if (fp.open(QIODevice::ReadOnly))
    		{			
    			int rlen = fp.read(buf,1024);
    			while (rlen>0)
    			{
    				for (int i=0;i<rlen;++i)
    				{
    					unsigned char c = hashfile>>56;
    					hashfile <<=8;
    					hashfile ^= (buf[i] ^ c );
    				}				
    				rlen = fp.read(buf,1024);
    			}
    		}
    		emit sig_done();		
    	});
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    这个类会开启一个独立的线程,做完后触发信号sig_done。上述代码是主干功能,相应的new,delete维护部分略去。如此一来,则需要在按钮响应函数里改造异步调用:

    
    
    void DlgCT::on_pushButton_thread_clicked()
    {
    	fileDealer * dealer = new fileDealer(this);
    	connect(dealer,&fileDealer::sig_done,[dealer,this]()->void{
    		dealer->deleteLater();
    	});
    	dealer->dealFile(ui->lineEdit_file->text());
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    即可完成非阻塞处理。

    3. 使用协程 co_await 同步风格编程

    如果使用C++协程,当然希望直接可以实现同步风格的异步调用:

    void DlgCT::on_pushButton_file_clicked()
    {
    	dealFile(ui->lineEdit_file->text());
    }
    FileTask DlgCT::dealFile(QString filename)
    {
    	QByteArray res = co_await awDealFile(filename);
    	//注意!若协程库开发不周到,此时有可能已经不是在主界面线程了!一定注意操作界面控件的线程安全性。
    	showMsg(res);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    在 co_await 语句后,返回主消息循环,此时定时器等依旧顺利工作。直到文件计算完毕后,才返回 showMsg(res);。为了达到上述效果,需要如下两步骤:

    3.1 添加协程代码

    首先,添加协程返回对象结构体. 本示例只使用 co_await关键词,所以大部分的必备函数入口都是默认值,啥也不做。

    /*!
     * \brief The FileTask class	协程结构体
     */
    struct FileTask
    {
    	struct promise_type;
    	using handle_type = std::coroutine_handle<promise_type>;
    	FileTask(handle_type h)
    	{}
    	FileTask(FileTask&& s)
    	{}
    
    	struct promise_type {
    		promise_type() = default;
    		~promise_type() = default;
    		auto get_return_object() noexcept {
    			return FileTask{handle_type::from_promise(*this)};
    		}
    		auto initial_suspend() noexcept {
    			//一创建立刻执行
    			return std::suspend_never{};
    		}
    		auto final_suspend() noexcept {
    			return std::suspend_always{};
    		}
    		void unhandled_exception() {
    			exit(1);
    		}
    		void return_void()
    		{}
    	};
    
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    3.2 创建 await 辅助类

    关键实现await功能的就是下面这个类:

    /*!
     * \brief The awDealFile class	协程 await 对象
     */
    class awDealFile : public QObject
    {
    	Q_OBJECT
    public:
    	awDealFile(QString filename, QObject *parent = nullptr)
    		:QObject(parent)
    		,m_fn(filename)
    		,m_pDealer(new fileDealer)
    	{
    		//处理完毕的信号,会在处理线程里发出,所以用QueuedConnection确保协程返回时,保持线程不变。
    		QObject::connect(m_pDealer,&fileDealer::sig_done,this, &awDealFile::slot_done,Qt::QueuedConnection);
    
    	}
    	~awDealFile()
    	{
    			if (m_pDealer)
    			m_pDealer->deleteLater();
    		m_pDealer = nullptr;
    	}
    	bool await_ready() {	return false;	}
    	/*!
    	 * \brief await_resume	这个函数的返回值决定了 await 关键词可以返回什么类型的东西
    	 * \return 哈希结果
    	 */
    	QByteArray await_resume() {
    		return m_pDealer->result;
    	}
    	/*!
    	 * \brief await_suspend	co_await 时,会调用这个函数。此时,启动处理,并在处理完毕后resume
    	 * \param h
    	 */
    	void await_suspend(FileTask::handle_type h) {
    		hd = h;
    		//处理
    		m_pDealer->dealFile(m_fn);
    	}
    private slots:
    	void slot_done()
    	{
    		if (hd)	hd.resume();
    	}
    private:
    	QString m_fn;
    	fileDealer * m_pDealer = nullptr;
    	FileTask::handle_type hd;
    };
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    有了上述代码,则可实现同步调用。

    4. 关于线程切换的风险

    协程的co_await 实际上提供了一个无栈的暂停-恢复框架。关键是要在确保处理完毕后,及时调用 resume 恢复执行。值得注意的是,对于从一个 std::thread内直接 resume的方法,会导致线程切换!此行为务必引起重视。在哪个线程调用的resume,协程函数恢复后,就回到哪个线程。这对操作GUI控件的代码带来了隐晦的风险!

    可以看到,在例子里使用Qt的跨线程队列槽 (Qt::QueuedConnection)确保恢复后的协程执行序依旧位于主线程。虽然在实验中,多线程操作控件似乎也没有报错,但这不是推荐的控件操作方法。

    	//处理完毕的信号,会在处理线程里发出,所以用QueuedConnection确保协程返回时,保持线程不变。
    		QObject::connect(m_pDealer,&fileDealer::sig_done,this, &awDealFile::slot_done,Qt::QueuedConnection);
    
       void slot_done()
    	{
    		if (hd)	hd.resume();
    	}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    5. 范例代码

    范例代码参考:

    https://gitcode.net/coloreaglestdio/qtcpp_demo/-/tree/master/qt_coro_test

    在 MSYS2 Qt6 /Linux下编译通过。
    范例工程

    6. 体会-协程用的香,协程库开发一点也不简单

    上述把一个异步操作变成同步,其实就是一个语法糖,背后还是多线程。如果一下处理1000个文件,开启1000个线程是不合理的,需要管理一个线程池,并管理请求队列,保证机械硬盘在一个合理的并发规模下运转。

    推而广之,协程能够发挥co_await的功效,仰赖于协程库背后的管理机制,如系统层面的异步回调(如socket)、库层面的线程池。一个简单的 co_await背后的代码量不容小觑。

    比较全面的协程改造的例子,参考这个基于Qt 的协程库 https://qcoro.dvratil.cz/,可以看见为了这一句“co_await”,库开发者要做的工作。

    此外,作为使用者,要搞清楚语法糖背后创建了哪些对象,生命周期如何,前后线程是不是一致,才能不踩坑。越是表面看起来无比清晰的代码,踩坑越是惊心动魄。所以如果是基于Qt这样的成熟框架,有Lambda槽回调,大可不必在生产环境激进地尝试协程。

  • 相关阅读:
    如何衡量CRM投资回报率?CRM系统如何提升投资回报率?
    Centos修改系统时间
    john 探测(爆破)弱口令(包含linux机器,aix小机)亲测可用
    丹青映画携梦枕貘巨著《暗狩之师》参加玩协四展
    Unity 编辑器资源导入处理函数 OnPreprocessTexture:深入解析与实用案例
    nvm 安装使用
    【货干】IP 配置出现意外。
    c++并行与并发
    Node.js的基本使用(四)项目实战——项目初始化及用户注册登录接口的实现
    【前端学习】—判断成立(十二)
  • 原文地址:https://blog.csdn.net/goldenhawking/article/details/136227836