C++ 多线程改造参考 / Reconstructing C++ functions to multi-threaded

Date:

Author:


本文为 2023-07-25 所做讲座的文稿,针对将 C++ 代码进行多线程改造的需求给出基础概念、相关技术和注意事项等,供参考。(注:文稿内容仅含概要,不做详细解释;文稿内容经过脱敏处理。)

This post is the presented script of the lecture given on 2023-07-25, aiming at guiding the reconstruction of C++ functions to multi-threaded ones, containing the basic concepts, related techniques, and practical notes. (Note: The script only contains essentials, most of which are not explained in detail; the script has been desensitized.)

(以下内容仅围绕互斥作展开,不讨论同步。)

1. 多线程基础 / Multi-threading basics

1.1. 并发、并行、多线程 / Concurrent, parallel, and multi-threading

术语英文(名词)英文(形容词)
并发concurrencyconcurrent
并行parallelismparallel
多线程multi-threadmulti-threading
表 1 术语

并行是并发的子集。或可认为:并行是物理表现,并发是抽象概念。

并发的关键是具有处理多个任务的能力(不一定同时处理),并行的关键是具有同时处理多个任务的能力。多线程的执行是否并行取决于处理器的构造(是否多核)和操作系统的调度(是否分配到不同的处理器核)。

另有一种抽象方法使用进程(process)、作业(job)、任务(task),并提出了同步(synchronous)、异步(asynchronous)的概念(需注意,这里的「同步」与并发中的「同步」意义不同)。多任务的并行即为异步。

参考:多线程、进程、并发、并行、同步、异步、伪并发、真并发 – 中二的羊 – 博客园

1.2. 线程安全 / Thread-safety

竞争条件(race condition):多个线程访问相同的内存位置时,存在某个线程修改了该位置的数据。

要做到线程安全,需要解决多个线程调用函数时访问共享资源的冲突,不发生对共享资源的竞争性操作,且程序的行为符合预期。为此引入「互斥」和「同步」。

互斥(mutual exclusion):使可能处于竞争状态的代码在同一时刻只能由一个线程执行,其他线程处于阻塞状态。

同步(synchronization):使多个线程在某个节点上相互等待或互通消息。

互斥通过对互斥量的加锁、解锁操作实现,同步通过条件变量(condition variable)、promise/future 或信号量(semaphore)的 wait/signal 操作实现。

1.3. 补充概念:可重入(Supplement: re-entrancy)

可重入函数就是可被中断的函数,即:可以在函数执行的任何时刻中断它,在操作系统的调度下去执行另外一段代码,再返回该函数时可继续执行而不出现错误。函数中如果使用了全局变量区、中断向量表等一些系统资源,被中断后可能出现问题。

要做到可重入,需要不在函数内部使用静态或全局数据,不返回静态或全局数据,也不调用不可重入函数。  

1.4. 线程安全性举例 / Examples of thread-safety

1.4.1. malloc(), free(), printf(), fprintf()

不可重入:假设某个进程调用的 malloc() 正在操作内存链表等共享资源时发生中断,而中断信号处理程序中也要使用 malloc(),共享资源会遭到破坏,所造成的结果将是不可预期的。

线程安全性由系统提供的运行时库保证。Visual Studio 中的设置项为「工程 → Property → C/C++ → Code Generation → Runtime Library」,可选项的说明参见 /MD, /MT, /LD (Use Run-Time Library) | Microsoft Learn(实际上都是线程安全的)。

1.4.2. STL 容器 / Containers in STL

STL 从语义上不提供任何强度的线程安全保证。

对容器的修改操作可能触发内存的重新分配,读容器时获取的指针、引用、迭代器等均可能因此而失效。为保证线程安全,需完成以下措施:

  • 每次调用容器的成员函数时需要锁定;
  • 容器迭代器的生命周期需要锁定;
  • 对容器调用算法时需要锁定。

1.4.3. Qt 类 / Qt classes

打开 Qt Assist,参考文章「Reentrancy and Thread-Safety」(或「thread-safe」):

操作互斥量需要额外的性能开销,所以多数 Qt 类不是线程安全的。(实际上,C++ 多数基础库和框架均不首先考虑线程安全性,需由程序员在完成具体需求时按需处理线程安全性。)

Qt 中的线程安全类主要是与线程相关的类(如 QMutex),线程安全函数主要是基础函数(如 QCoreApplication::postEvent())。

1.4.4. 隐式共享与线程安全 / Implicit sharing and thread-safety

Qt 基础数据结构和容器采用的隐式共享机制可保证引用计数的线程安全,并提供了 QSharedDataQSharedDataPointer 用于方便地实现隐式共享。

需注意,引用计数的线程安全性并不保证相关类的操作是线程安全的。  

1.4.5. 扩展阅读 / Further reading

[1] Thread Safety in the C++ Standard Library | Microsoft Learn

1.5. C++ 并发支持库 / C++ concurrency support library

C++11 通过以下组件提供对并发的支持:

  • thread 等:线程
  • async:函数的异步调用
  • atomic 等:不可交替的(non-interleaved)值访问(原子操作)
  • mutex 等:互斥量,标记不可同时执行的操作
  • condition_variable 等:条件变量,用于线程间通信
  • promisefuture:用于存储和访问来自异步过程的结果

C++20 通过 <semaphore> 引入了用于线程间同步(synchronization)的信号标(semaphore),用于约束对共享资源的访问。

参考资料:

[2] Concurrency support library – cppreference

1.6. 扩展阅读 / Further reading

[3] Anthony Williams. C++ Concurrency in Action, Second Edition [M]. Manning, 2019-02.

注:第一版中文翻译见 bookstack.cn,翻译质量不佳。

2. 多线程实用手段 / Practical techniques for multi-threading

2.1. 互斥量 / Mutual exclusion

2.1.1. Mutex

互斥量可用于保护共享数据,或设计并发数据结构。

std::mutex 类提供了 lock()unlock() 等方法用于加锁、解锁。

应首先考虑使用 std::lock_guard<std::mutex> 或等效操作以符合 RAII 机制,避免代码中的意外跳转或程序异常导致互斥量未解锁、引起线程阻塞。

Qt 提供了 QMutexLocker

2.1.2. 名词解释:RAII / RAII

RAII(Resource Acquisition Is Initialization,资源获取即初始化),将所需资源(分配的堆内存、执行线程、打开的套接字、打开的文件、锁定的互斥体、磁盘空间、数据库连接等)的生命周期与一个对象的生存期绑定,即在类的构造函数中申请资源(可抛出异常),在类的析构函数中释放资源(不可抛出异常)。

如果使用 new 在堆上创建对象,则可能存在程序出错而未能执行 delete 的情况。对于互斥量的操作,如果直接使用 lock(),同样存在因程序出错(含逻辑错误)而未能转到 unlock() 的情况。使用 RAII 机制可避免此类问题。

2.2. 原子操作 / Atomic operations

使用原子操作可定义无锁的并发数据结构。

优点:提高并发访问能力。

缺点:设计复杂,影响执行性能。

2.3. 线程管理类 / Thread manager

QThread 是用于管理线程的类,而不是线程本身。通过继承 QThread 实现线程功能是 Qt 4.4 之前的做法,因为当时的 QThread::run() 是虚函数;从 Qt 4.4 开始,QThread::run() 默认调用 QThread::exec(),线程类只需继承 QObject

注:QThread 子类的信号无法连接到 QObject 子类的槽(可以连接到 QWidget 子类),所以推荐按照 QThread 文档中的例子,使用 QObject::moveToThread()

参考文章:

[4] You’re doing it wrong… – Bradley T. Hughes

[5] Qt 的线程与事件循环 – FreyrLin

2.4. 线程池 / Thread pool

QThreadPool 可用于管理大量线程,超过最大线程数的部分将自动进入等待状态。可使用 QWaitCondition 处理复杂的线程等待问题。

注:QThreadPool 所启动线程的所有者不是系统的当前用户。若有线程状态异常、未在程序中处理而手动结束了主进程,则这些异常线程可能无法手动结束,其所占用的资源也无法释放。此时,注销用户也无法解决问题,只能选择重启系统。

名称空间 QtConcurrent

  • 可视为对 QThreadPool 等的再封装(另提供了 QFuture 等功能),隐藏了一些细节,底层逻辑上不存在新的内容,仍是使用 QThreadPool::globalInstance()QThreadPool 实例管理线程;
  • run() 可执行的函数需为静态函数;
  • 传参不可改变,仅当参数为指针或参数中存在指针时可修改指针所指的对象,但仍需注意线程间的竞争问题;
  • 已运行的线程无法取消、暂停。

3. 多线程改造 / Notes on multi-threading reconstruction

3.1. 可用线程数 / Number of usable threads

对已有功能进行多线程改造,其目的一般是利用 CPU 的多核,通过并行执行部分过程以显著提高执行效率。

可同时执行的线程数可以等于 CPU 核数,但一般建议将最大线程数设置为 CPU 核数的 ½~¾。更大的最大线程数基本不再能提高执行效率,可能是因为一些后台进程会占用少量 CPU 核。

3.2. 识别外部不可共享的资源 / Identifying unshareable external resources

实践中,转移到线程中的处理过程可能存在通过指针或其他隐蔽手段修改外部资源并使用的情况,且对该资源的使用跨越复杂的流程,则不能对该资源的完整使用过程加锁(否则将极大地影响并行性能),此类不可共享的外部资源应当在线程中保留副本。

3.3. 其他注意事项 / Other notes

One thing to watch out for is that library calls can use internal variables to store state, which then becomes shared if multiple threads use the same set of library calls. This can be a problems because it’s not immediately apparent that the code accesses shared data.  

C++ Concurrency In Action, 2nd Edition(C++并发编程·第二版)第 11.2.3 小节

即,有的库会使用内部变量存储状态信息,当多个线程调用这个库中的函数时,该状态就会被共享而可能导致错误。

4. 多线程调试方法 / Debugging multi-threaded functions

以 Visual Studio 为例,阅读系列文章:Debug multithreaded applications in Visual Studio | Microsoft Learn

主要调试窗口:「Debug → Windows → Parallel Stacks」以框图显示各线程间的调用关系,「Debug → Windows → Threads」列出各线程状态及各自的调用栈。

5. 多线程改造实例 / Case study of multi-threading reconstruction

(略)


Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.