线程安全接口的设计注意点

在多线程下, 最基本的保护数据的做法是使用互斥量来做保护. 比如, 标准C++库中的std::lock_guard, Qt中的QMutexLocker等.

注意点:

  • 要注意检查是否向调用者提供了指向受保护的数据的指针或引用, 这些是危险的

  • 要注意若内部函数调用了别的不受我们掌控的函数, 而且向这些函数传递了指针或引用, 也是危险的.

  • 即使单个接口安全, 也要考虑接口之间的固有竞争问题. 因此要注意接口设计. 注意两个安全的接口调用之间的状态发生变化导致第一个接口的结果不可信的问题.

    比如, 一个stack提供了size()top()两个接口, 每个接口都是线程安全的, 但是客户先调用size()判断栈非空, 再调用top()获取栈顶元素, 这个逻辑就不是线程安全的了.

    因此, 尽量使用单一接口实现判断-操作等工作, 不要让线程被打断.

    在低负载的情况下, 也可以考虑在外部对整个数据接口再加上加互斥. 这是以效率为代价求得安全, 不是讨论的范畴. 并且, 甚至可能导致死锁, 更不值得考虑.

  • 另一个要考虑的地方是异常的安全性. 应当尽量避免让构造函数抛出异常. 尤其是在stl数据容器中.

    考虑stack,

    • 假定其pop()函数的行为是: 返回栈顶的元素, 并将其从栈上移除.
    • 这样带来了隐患: 只有在栈被改动后, 弹出的元素才会返回给用户. 如果数据在向用户复制的过程中产生了异常, 就会导致用户未得到数据, 而数据已经从栈上给移走了.

在stl中, 解决这个问题的做法是将操作分割成top()pop()两个接口. 这样如果top()抛出异常, pop()未被执行, 数据还留在栈中.
而这个设计和多线程的尽量提供单一接口的要求矛盾了.

一个例子, 考虑一个线程安全的栈的pop()设计

方法1: 使用一个外部变量接收pop的结果.

大部分时候行之有效. 几个使用限制:

  • 当结果的构造实例的代价高昂时
  • 当实例没有缺省构造函数时
  • 当栈容器存储的型别是不可赋值的时

方法2: 提供不抛出异常的构造函数或不抛出异常的移动构造函数

可以使用std::is_nothrow_copy_constructiblestd::is_nothrow_move_constructible两个trait对型别做判断. 但是限制太多.

方法3: 返回指针, 指向弹出的元素

使用std::shared_ptrQSharedPointer等指针作为栈的数据型别, 而不是裸数据.

这个方式其实在很多时候也不是很好, 抛开方便的问题, 它最大的问题是cache一致性问题. 尤其是对于内存连续的数据结构, 我们本来可能可以期望容器内部的连续内存假定. 现在就必须做二次寻址. 对高性能场景来说就不好了.

不过好在这两种场景一般并不会同时出现.

综述

结合1和2, 或者1和3, 作为两种比较可以接受的措施.

一个栈, 如果使用方法1和3来改造, 则其接口提供:

1
2
3
4
5
6
7
8
9
10
11

template <typename T>
class threadsafe_stack
{
//...
void push(T new_value);
std::shared_ptr<T> pop();
void pop(T& value);
vool empty() const;
}

我们让pop函数在栈空的时候抛出异常. 例如, pop()函数的实现为:

1
2
3
4
5
6
7
8
std::shared_ptr<T> threadsafe_stack()
{
std::lock_guard<std::mutex> _lock(_mutex);
if(_data.empty()) throw ex_empty_stack();
std::shared_ptr<T> const res{std::make_shared<T>(_data.top())};
_data.pop();
return res;
}

我认为这个方案有个前提条件是, stack是链表实现方式, 而不能是连续内存实现方式. 因为被pop出去的元素实际上并没有从底层内存中被移除. 这样, 下次再push的时候, 不知道会发生什么. 或许会发生重新给stack分配内存?
例如, Qt的QStack就是QList的派生类. 而QList实际上是QVector的实现, 而不是真正的linkedlist.