0%

异步编程小结 - IO 模型

基本概念

异步编程通常出现在涉及到 IO 操作(网络、磁盘操作等)的情境中。由于 IO 操作较为耗时,在默认情况下,如果一个进程和线程(对于拥有多个线程的进程)进行了 IO 操作,通常会导致这个线程被阻塞(blocked)进而被挂起,在线程请求的资源准备好之后(例如读取/发送数据完成),这个线程才会重新被设置为 ready 等待执行。线程被阻塞时无法进行其他计算,这导致计算资源无法被最大化利用,甚至在某些单线程应用中,会导致应用直接卡死。为了解决这一问题,我们需要使用“异步”模式。

在涉及异步的编程中有两对经常出现的概念:

  1. 同步/异步 (synchronous v.s. asynchronous)

    同步和异步表示进程/线程之间(或是用户线程与系统内核之间)的协作方式。

    同步:数据的发送和接收方的步调一致,例如发送方需要在确认数据发送成功后,才会继续执行后续的其他逻辑,接收方在确认接收到数据(或发现没有其他可读的数据)后,才可以继续执行

    异步:发送和接收方不需要保持一致步调,发送方在发送数据后不需要等待是否发送成功,接收方可能会读取到数据,也可能读取不到

  2. 阻塞/非阻塞 (blocking v.s. non-blocking)

    阻塞/非阻塞表示线程在进行了一次(IO)操作之后的状态

    阻塞:发送或读取数据时,如果没有立即发送或读取成功(大多数情况不会立即成功),线程会被阻塞,在发送或读取完成之前无法进行后续的操作

    非阻塞:线程在发起一个发送或读取的操作后,即使操作没有完成,线程也不会被阻塞,可以直接开始执行后续的操作

常见的 IO 模型

下面的模型分类及图片来自《Unix 网络编程》,section 6.2

一次 IO 操作(以网络 IO 为例)可以粗略地分为两个阶段:

  1. 发送数据:
    1. 线程中要发送的数据复制到内核空间
    2. 发送数据到远程
  2. 接收数据:
    1. 接收网络数据
    2. 从内核将数据复制到用户空间(即读取线程的内存)

根据在不同阶段用户线程的行为和状态(是否阻塞,在复制数据时是否使用同步的方法),可以划分出几种常见的 IO 模型:

  1. 阻塞 IO 模型
  2. 非阻塞 IO 模型
  3. IO 复用
  4. 信号驱动 IO
  5. 异步 IO

阻塞 IO 模型

阻塞 IO (blocking IO)是默认的 IO 模型,线程在进行 IO 操作时会等待数据就绪,而在此期间线程被阻塞(表现为线程在调用 send/recv 时卡住),不能进行其他任何操作,因此不利于进行高并发的网络 IO,但由于在 send/recv 函数调用返回之后即可以保证数据已经读写完毕,因此在逻辑上最为简单和直观。在阻塞 IO 中,用户线程从发起系统调用开始被阻塞,一直到数据被内核读取并复制带用户空间。由于内核进行数据复制时,用户线程在等待这个操作完成,因此我们可以说这个操作是同步的。

如果希望使用阻塞式 IO 并发地处理多个 socket,一种可行的做法是使用多个线程,每个线程只处理一个 socket。由于线程是调度的基本单位,一个线程被阻塞时并不会影响到其他的线程执行。但需要注意的是,虽然比进程更加轻量,线程依然会占用内存(每个线程有独立的栈空间),并且线程在切换时会有耗时(由内核进行调度,会涉及上下文切换,即 context switching),因此相比后面提到的 IO 复用模型,多线程+阻塞 IO 的模型能承受的并发量通常会更小。

blocking IO

非阻塞 IO 模型

非阻塞 IO (non-blocking IO)与阻塞 IO 的不同点在于,当线程向内核请求读取数据时,如果数据尚未就绪,线程不会被阻塞(即 recv 函数会立即返回),并且线程会收到一个返回值(即图中的 EWOULDBLOCK 错误),表示要读取的数据还没有准备好。如果有网络数据就绪,线程进行 recv 的系统调用会使得内核开始复制接收的数据到线程的内存空间中,在复制结束后 recv 函数才会返回。因此在这样的非阻塞 IO 中,数据的读取依然是同步的

非阻塞 IO 可以解决阻塞式 IO 无法处理大量并发的 IO 操作的问题,因为在等待数据就绪的时候线程可以执行其他的操作。但是由于不能保证 recv 返回后数据就一定被成功读取,用户线程需要不停检查数据是否就绪,这一做法成为轮询(polling),而通常轮询会浪费大量的 CPU 时间(通常表现为线程将一个 CPU core 占满)。因此在实践中单纯的非阻塞 IO 并不常见。

需要注意的是,虽然系统调用不会阻塞线程,但在数据从内核复制到用户空间(即线程的内存)时,用户线程仍然会进行等待复制完成,因此这个读取数据的操作依然是同步的。

non-blocking IO

IO 复用模型

IO 复用(IO multiplexing)模型使用的是阻塞的系统调用 select, poll, epoll非阻塞的 socket 的组合。IO 复用的优势在于可以在单个线程中同时处理多个 socket,且不需要在用户线程进行 polling,避免了非阻塞模型中的 CPU 消耗。

select 为例,线程在调用 select 后会阻塞,并等待某(几)个 socket 的数据就绪。当有数据就绪时, select 调用会返回可以读取的 socket,用户线程可以接着读取数据并进行对应的处理。处理完成后可以继续调用 select 等待下一次数据就绪。

IO 复用是目前较为常用的一个 IO 模型,在一些大型的项目中都用应用,例如 nginx。同样需要注意的是,在这个模型中,就 recv 调用而言依然是同步的,因为数据从内核空间复制到用户空间的过程中 recv 依然会进行等待。

上面提到的三个系统调用 select, poll, epoll 是历史上先后出现的几个多路复用实现。其中 select 出现最早,实现也最为简单,但是有最多同时处理 1024 个 socket 的限制(可以配置),效率也较低; pollselect 之后出现,修复了 select 中的若干问题,包括 1024 个 socket 的限制,而 epoll 是最新出现的,也是效率最高的。

IO multiplexing

信号驱动 IO

信号驱动 IO (signal-driven IO)模型与上述几个模型的最大不同在于,线程不会直接去询问内核是否有数据可以读取,而是注册一个信号处理函数(signal handler),当内核发现数据就绪时,会产生 SIGIO 信号并发送给用户线程,进而用户线程可以开始读取数据。

这种模型的优势在于不会有任何阻塞(除了调用 recv 时产生的等待),也不需要频繁进行轮询,但 recv 调用依然是同步的。

signal driven IO

异步 IO

异步 IO (asynchronous IO)模型需要依赖 POSIX 的异步 IO 函数,将 socket,用户线程的缓冲区指针等传递给内核,当内核中检查到有数据就绪并且复制到用户空间的缓冲区之后,通知用户线程进行处理。在这个模型中,用户线程不会等待数据就绪,也不会等待数据从内核复制到用户空间,相比于前几种 IO 模型,异步 IO 的读取操作也不会进行任何等待。

asynchronous IO

小结

《Unix 网络编程》书中提供了一张总结对比图,描述和对比了几种 IO 模型中用户线程和内核的行为:

summary

在网络编程中,目前较为常用的 IO 模型应该是 IO 复用模型:一方面 IO 复用可以利用较少的资源高效地处理大量并发 IO(尤其是使用 epoll 的实现),另一方面这种模型相对于信号驱动 IO 和异步 IO 更容易理解(例如不需要理解信号何时触发、由哪个线程处理等问题)。而非阻塞 IO 模型在一些分布式计算的场景中也常见到,例如 MPI 中的 MPI_Isend 函数,在这类场景中,程序通常不需要处理大量的并发 IO,而使用非阻塞 IO 将通信时间与计算时间重叠,可以减少程序的整体耗时。

INTRO

Redis = REmote DIctionary Server, 是一个由 C 语言编写的基于内存的可持久化的key-value 数据结构存储,可用作缓存、数据库等。

基本构成:

  • 服务端 redis-server
  • 客户端 redis-cli
  • 同时有多种语言的 API,例如 Python 的 redis-py

基本数据结构:

Redis 中提供五种基本数据结构:

  • string 字符串,Redis 中的 string 是 binary-safe 的,也即可以是任何一段二进制数据,而不仅仅是保存文字的字符串,一个 string 的长度上限是 512 M;
  • list 列表,保持插入顺序的链式列表,元素为 string,需要注意的是 list 仅支持在首部或尾部读写数据;
  • set 无序集合,元素为 string,常规意义上的集合;
  • ordered set 有序集合,元素为 string,每个元素可以有一个权重 score,集合中的元素按照权重有序,支持按照权重的值或名次获取数据,例如返回权重 = 2,或者权重第二大的元素,同时支持按照范围批量读取;
  • hash 哈希,保存一系列 field → value 的映射,注意这里的 field 和 value 只能是 string 类型。Hash 可以看作是一个仅支持 string 类型的 map(或 Python 中的 dict)

在 Redis 中我们按照 key-value 的形式来存取数据,其中的 key 是一个 string,同样是 bianry-safe 的,而 value 的类型则可以是以上的类型之一。在 Redis 中可以用基本的数据类型来实现更加高级的数据类型,如 bitmap 等,参见 Redis 文档

使用&基础命令

连接服务器

可以在命令行中直接执行  redis-cli 来连接 redis 服务器:

redis-cli -h HOST -p PORT [COMMAND]

1
2
3
➜  ~ redis-cli -h 127.0.0.1 -p 6379 ping
PONG
➜ ~

如果服务器设置了密码,可以加上 -a PASSWORD 进行认证

Redis 基本命令

Redis 命令的基本格式为

COMMAND arg1 arg2 ...

其中 COMMAND 可以是数据读写的命令,如 get, set,也可以是其他的管理命令,如 config, save, monitor 等, arg* 为参数,根据不同的命令,参数的数量和格式会有所不同。

常用的管理命令

类似于 MySQL 等数据库,Redis 也提供了一些与读写数据无关的命令用于服务本身的管理和状态检查等,常用的有:

  • PING 检查服务器,服务器正常运行时返回 “PONG”
  • INFO 返回服务器状态
  • AUTH 进行认证
  • TIME 返回服务器时间
  • MONITOR 实时监听服务器收到的所有请求,debug 神器,生产环境慎用,会导致服务器性能显著下降

基本的数据读写操作

Redis 中以 key 为中心对数据进行操作,对于不同 value 的类型需要使用不同的命令。在 Redis 中修改或读取一个 key 对应的 value 时并不需要事先进行类似声明变量的操作,如果 key 不存在,Redis 会根据不同的命令自动创建对应的数据结构或返回空的 value。对于一个已经存在的 key,如果使用了和 value 的数据类型不匹配的命令,例如对 string 类型使用插入元素的命令,Redis 会返回错误。注意,Redis 中的每次操作都具有原子性(atomic),这一点为 Redis 的使用带来很多便利。

Key

对于与 key 对应的 value 的读写操作,一般需要根据 value 的类型使用不同的命令,但也存在一些与类型无关的命令:

  • 检查 key 是否存在 EXISTS key
  • 检查 key 的类型 TYPE key
  • 查找符合某个 pattern 的 key KEYS pattern
    • pattern 支持 ? - 单个任意字符;* - 任意个任意字符;[a] - 字符的匹配,支持范围 - 和否定 ^,仅匹配单个字符
    • 生产环境慎用
  • 删除 key DEL key
  • 重命名 key RENAME oldkey newkey
    • 仅当 newkey 不存在是重命名 RENAMENX oldkey newkey
  • 读取或设置 key 的过期时间
    • EXPIRE keyPEXPIRE key milliseconds 指定的秒(毫秒)之后将 key 过期(设置 time-to-live, TTL)
    • EXPIREAT key timestamp 在给定的时间将 key 过期,timestamp 为 Unix 时间戳
    • TTL keyPTTL key 返回 key 的 TTL,单位秒(毫秒)

其他命令及详细文档参见 https://redis.io/commands#generic

String

字符串(string)是 Redis 中一种最基本的数据类型。可以通过 GET keySET key valueSETEX key seconds value 等进行读写操作。 同时 Redis 支持在一条命令中同时设置多个 key 的值,可通过 MGETMSET 同时读写多个 key。在 Redis 中,list、set 等类型中的基本元素都要求是 string,但 string 同时支持很多有用的特性,例如:

  • binary-safe: string 可以被 Redis 解释为一个二进制串,因此可以保存非文本的数据,例如图片等数据可以直接保存为一个 string,但注意长度上限
  • string 中存储的内容可以解释为数字(整数、浮点数)时,可以使用 INCRDECRINCRBYDECRBYINCRBYFLOAT 修改 string 所表示的数字的值,但修改后类型依然为 string。例:
1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6379> set key 123
OK
127.0.0.1:6379> get key
"123"
127.0.0.1:6379> incrby key 2
(integer) 125
127.0.0.1:6379> get key
"125"
127.0.0.1:6379> type key
string
127.0.0.1:6379>
  • string 支持按位读写,可以利用这一点使用 string 实现 bitmap,对应的命令有 GETBITSETBIT 等。下面的例子中,字符 a 的 ASCII 码为 97,二进制表示为 01100001c 的二进制表示为 01100011(ASCII 码为 99)
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
127.0.0.1:6379> set key 'a'
OK
127.0.0.1:6379> getbit key 0
(integer) 0
127.0.0.1:6379> getbit key 1
(integer) 1
127.0.0.1:6379> getbit key 2
(integer) 1
127.0.0.1:6379> getbit key 3
(integer) 0
127.0.0.1:6379> getbit key 4
(integer) 0
127.0.0.1:6379> getbit key 5
(integer) 0
127.0.0.1:6379> getbit key 5
(integer) 0
127.0.0.1:6379> getbit key 6
(integer) 0
127.0.0.1:6379> getbit key 7
(integer) 1
127.0.0.1:6379> setbit key 6 1
(integer) 0
127.0.0.1:6379> get key
"c"
127.0.0.1:6379>
  • string 支持子串的读取和更新,对应的命令为 GETRANGE key start endSETRANGE key offset value 等,可以使用 STRLEN key 获取 value 的长度

其他命令及详细文档参见 https://redis.io/commands#string

List

Redis 中的 list 在内部使用 doubly linked list 实现,因此在随机位置上进行存取有较高的复杂度,如果需要大量的随机位置的访问,应该使用 sorted set。显然,使用 list 可以模拟出队列和栈的结构。

关于 list 的基本操作有:

  • 链表两端的元素存取:
    • LPOP key
    • LPUSH key v1 v2 ..
    • RPOP key
    • RPUSH key v1 v2 ..
    • LREM key count element
  • 链表任意位置的读写:(与链表类似,以下操作都具有线性的平均时间复杂度)
    • LRANGE key start stop 读取 start 到 stop 位置的元素
    • LSET key index element 将 index 位置的元素设为 element
    • LINSERT key BEFORE|AFTER pivot element 在值为 pivot 的元素前或后插入 element

List 结构比较常用的场景:

  • 用作任务队列

    某些场景下我们会使用生产者-消费者模型,由生产者产生的任务(例如启动一个自动化流程)可能会由多个工作进程中的某一个来进行消费执行。这样的进程间通信可以使用 Redis 中的 list 实现:

    • 一个任务产生后,生产者使用 LPUSH master_queue task 将任务加入队列
    • 当某个 worker_n 处于空闲状态后,使用 RPOPLPUSH master_queue worker_queue_n 将 task 加入自己的 worker 队列并开始执行 task
    • worker 完成后使用 RPOP worker_queue_n 删除 task

    这里需要注意的是对 RPOPLPUSH 的使用。这个命令的作用是从一个 list 中删除元素并放到另一个队列中。另一种做法是 worker_n(或调度进程)先使用 RPOP 取出 task,之后使用 LPUSH 放入 worker 的执行队列,但这种做法在网络不稳定或 worker 进程本身崩溃时,可能导致 task 丢失。例如 worker 在 redis server 执行 RPOP 后,由于网络异常没有收到 task 的内容,或 worker 在执行 LPUSH 之前进程崩溃,这两种情况都会导致 task 的内容丢失。而使用 RPOPLPUSH 来进行数据的转移,可以避免这样的情况发生,因为每个 Redis 的命令都具有原子性,当网络异常或 worker 崩溃时,task 数据依旧会被保留在待执行队列被其他 worker 执行,或被保留在 worker 的任务队列中,等待 worker 恢复后继续执行。

  • 获取(或仅保留)最新的 n 条记录

    上面的例子中,list 本质上是被作为队列使用了,而对于最新的 n 条记录这样的场景,我们可以将 list 作为栈来使用,例如:

    • 获取用户最近使用过的 k 个用户名:由于我们只关心用户最近使用过的 k 个用户名,更加久远的数据没有保留的必要,因此可以在用户 x 修改用户名为 name 时执行:

      • LPUSH username_history_x name
      • LTRIM username_history_x k

      这里的 LPUSH 用来保存最新的用户名 name,而 LTRIM 则会丢弃第 k 个之后的名字

    • 获取用户的最近 k 条动态:

      • 在用户 x 更新动态时执行 LPUSH user_updates_x update_id
      • 需要访问用户的最近 k 条动态时执行 LRANGE user_updates_x 0 k

      需要注意的是,由于 Redis 是基于内存的,因此数据库的大小收到可用内存数量的限制,如果动态的数据较大(例如视频文件),在 Redis 中只保留动态的 id,具体内容在其他数据库中储存。关于 LRANGE 在这里的使用,尽管这个命令具有线性的平均时间复杂度,但这个场景下我们使用从列表的头部读取数据,因此时间复杂度是最优的 O(k)

其他命令及详细文档参见 https://redis.io/commands#list

C++ 11 通过 std::thread 提供了多线程支持,相对 pthreadstd::thread 提供了一套更高层次的抽象,使得线程相关的操作更加轻松

线程创建

首先需要包含头文件 #include <thread>

其次,需要提供该线程需要执行的函数,可以是函数指针、函数对象或者 lambda 函数,但返回类型需要为 void

创建线程对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class SomeClass {
public:
void operator() () { ... }
};

void func() { ... }

int main() {
// function pointer
std::thread thd_func_ptr(func);
// function object
SomeClass obj;
std::thread thd_func_obj(obj);
// lambda function
std::thread thd_lambda([] { ... });
...
}

线程对象被创建后会立即开始执行

等待某个线程完成:thd_xx.join()

获取线程 ID:int thd_id = thd_func_ptr.get_id()

获取当前线程 ID:int this_id = std::this_thread.get_id()

线程的 joindetach 以及 joinable 状态

在 C++ 标准库的多线程模型中,std::thread 对象被创建后,会在新的线程中执行对应的函数,创建的 thread 对象则会与该线程相关联。而在 thread 对象被销毁时,该 thread 对象必须已经与对应的线程解除关联(thd.joinable() == false),该操作可以通过 detach 或者 join 完成:

1
2
3
4
5
std::thread thd1([] { ... });
thd1.join(); // wait `thd1` to finish

std::thread thd2([] { ... });
thd2.detach(); // make `thd2` a daemon (background)

join: 调用某个 thread 对象的 join 方法后,调用的线程将被阻塞,直到该 thread 对象所关联的线程执行结束,join 结束后,该线程对象的 joinable 属性会变为 false

detach: 调用 thread 对象的 detach 方法后,对应线程成为后台线程(或 “守护线程”),thread 对象的 joinable 属性变为 false

joinable: 标记某个 thread 对象是否与某个线程相关联,可以通过 thd_obj.joinable() 获取该线程对象是否处于 “joinable” 的状态。当 thread 对象被销毁(析构函数被调用)时,该对象需要满足 joinable() == false,否则程序会抛出异常,中止执行;同时,在调用 .join().detach() 时,线程需要处于 “joinable” 的状态。因此以下两点需要尤其注意:

  • 对一个 std::thread 对象执行执行一次 .join() 或者 .detach()。因为需要 thread 对象处于 “joinable” 状态才可以调用这两个方法
  • 对于一个 std::thread 对象必须执行一次 .join() 或者 .detach(),否则 thread 对象在被销毁时依旧处于 “joinable” 状态,导致异常

线程间数据共享和参数传递

线程在创建时可以指定要执行函数的参数:

1
2
3
void func(int a) { ... }
...
std::thread thd(func, 123);

在使用参数时有几点需要注意:

  • 使用指针作为参数时注意不要传递局部变量的指针,例如:

    1
    2
    3
    4
    5
    6
    void func(int* a) { ... }
    void caller() {
    int a = 123;
    std::thread thd(func, &a); // NEVER do this
    ...
    }

    同一进程的多个线程之间会共享内存,虽然各个线程会有自己的调用栈,但是某个线程的栈内存依然是可以被其他线程访问,上面的例子可能可以正常执行,但 func 中访问指针 a 的时候可能 caller 函数已经执行结束,此时再访问 a 会出现不可预期的错误。

  • 使用引用作为参数时需要使用 std::ref()std::cref(),例如:

    1
    2
    3
    4
    5
    6
    void func(int& a, const int& b) { ... }
    void caller() {
    int a = 123, b = 321;
    std::thread thd(func, std::ref(a), std::cref(b));
    ...
    }

    需要这样做的原因于上面一条类似,func 访问到 a 或者 b 时,原本的变量可能已经销毁,因此,如果直接传递变量,在 func 中会得到一个对于临时变量的引用。由于这条限制,如果需要在被调用的线程中获得某个变量的引用,需要使用 std::ref 或者 std::cref 进行包装,或在 func 中通过其他方式获得引用,例如全局变量或者将引用设置为自己的成员变量。

互斥量(mutex)、锁(lock)、条件变量(condition variable)

C++ 11 提供了互斥量、锁以及条件变量的实现,可以方便地实现线程之间的同步和事件通知。

mutex & lock

mutex 可以和各种锁配合使用,例如 std::unique_lock,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::mutex mutex_obj;
void f1() {
std::unique_lock<std::mutex> lock(mutex_obj);
// this section is protected by the lock
...
lock.unlock();
...
}
void f2() {
...
{
std::unique_lock<std::mutex> lock(mutex_obj);
...
} // the lock is unlocked automatically
}

unique_lock 对象在被构造时会自动加锁,在代码中可以手动调用 unlock 方法释放锁,或者在 unique_lock 对象被销毁时(delete,或生命周期结束时,例如 f2 中代码块结束时)自动释放锁。

condition variable

在处理并发任务时经常会遇到某个线程需要等待其他线程完成某个操作后才可以继续执行的情况。例如 4 个线程同时从同一个任务队列中取出任务然后执行的场景。在这种场景下,每个线程需要检查任务队列是否为空,一种可能的实现方式是每个线程不断查询任务队列的长度:

1
2
3
while (task_queue.empty()) {
sleep(1);
}

这种 busy wait 的方式会频繁的唤起线程,占用大量的 CPU 时间,同时考虑到线程安全,在查询队列长度时应该考虑加互斥锁,这样会进一步到来额外负担。为了解决这个问题,可以使用条件变量实现线程之间的事件通知。下面是一个关于 condition variable 的简单示例, func2 向任务队列中添加任务,func1 取出任务并执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
std::condition_variable cv;
std::mutex mutex_obj;
std::deque<int> tasks;

void func1() {
int t = 0;
{
std::unique_lock lock(mutex_obj);
cv.wait(lock, []() { return !tasks.empty(); });
int t = tasks.pop_front();
lock.unlock();
cv.notify_one();
}
...
}

void func2() {
std::unique_lock lock(mutex_obj);
task.push_back(123);
lock.unlock();
cv.notify_one();
}

上面的例子中,执行 func1 的线程会先获取 lock 锁,之后在 cv.wait 释放锁,然后被阻塞并一直等待,直到某个线程调用了 cv.notify,此时收到信号的线程会被唤起并重新获得锁 lock,同时检查条件是否成立(!tasks.empty()),如果条件成立,wait 将会退出,线程继续执行,否则会重新进入 wait 进行等待。通过 condition variable 可以避免线程进行 busy wait 带来的性能消耗。

类似地,在 func2 中,该线程会向 tasks 中添加任务,在这里需要注意的是,通过锁 lock,所有对于 tasks 队列进行的操作都是互斥的;在这里 lock.lock() 调用不是必要的,因为在代码块结束时 lock 会自动释放锁,但如果 cv.notify_one() 之后有其他耗时的逻辑,需要考虑手动释放 lock,因为 cv.wait() 会尝试加锁,不执行 lock.unlock() 在某些情况下可能会造成死锁。

获取返回值:promise/future

C++ 11 中提供了 std::promisestd::future 以便于执行线程将数据返回给调用线程。相比于使用指针或引用传递返回值的方式,promise/future 提供了更加便利的操作:

1
2
3
4
5
6
7
8
9
10
11
void f1(std::promise<int> p) {
p.set_value(123);
}

void f2() {
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread thd(f, std::move(p));
int a = f.get(); // wait until `p->set_value` is called
...
}

promise/future 中,每个 promise 对象会关联一个 future 对象,可以通过 get_future 获得,调用线程将 promise 对象作为参数传入,在被调用线程中通过 set_value 设置返回结果。与直接使用指针或引用传递返回值的方法不同的是,调用线程通过 f.get() 获取被调用线程设置的值,在 set_value 被执行之前,get 方法会阻塞,直到 set_value 被调用。

References

This is the very first hello world post.

Welcome.

0w0