C++中多读少写共享数据的保护操作

  开发过程中读多写少的情况还是很常见的,比如很多服务运行参数或者业务配置参数,这些内容只有在更改配置文件或者数据库的时候才需要重新加载,执行写操作,平常大多数时候都是只读的。对于这些配置,我暂时的处理方式(比较low)是手动向服务发送一个信号(SIGHUP,跟nginx学的),然后在服务的信号处理中进行加载和更新替换操作,虽然多线程情况下信号机制复杂的要死,大神建议的情况就是能不用就不用,但是因为服务端没有HTTP的支持,所以那些RESTful优雅的更新操作也没法使用,实乃无奈之举。曾经也在网上尝试找一些配置管理工具,但是都觉得用起来太重,因此也一直这么将就着了。
  话题转回来,对于读写数据,最常用的方式就是使用一个读写锁来进行保护,其使用基本都是下面的套路来完成的:

1
2
mutable boost::shared_mutex rwlock_;
std::map<std::string, channel_health_t> cached_health_;

  然后,读写访问起来也很清晰,使用shared_mutex和unique_mutex就可以解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool PbiChannelRoute::is_channel_health(std::string channel_name) const {
boost::shared_lock<boost::shared_mutex> rlock(rwlock_);
auto iter = cached_health_.find(channel_name);
if ( iter != cached_health_.cend()){
return it->second.is_health;
}
return true;
}
void PbiChannelRoute::update_channel_health() {
....
{
boost::unique_lock<boost::shared_mutex> wlock(rwlock_);
cached_health_ = channel_health;
}
...
}

  在读访问的时候,我们使用shared_lock来用作为读锁,而写的时候使用unique_lock排他锁作为写锁,同时在更新的时候我们注意到需要尽量把这个操作的临界区设置的最小,更新前的数据准备工作以及更新完的善后工作都丢到临界区之外执行。这个模型的优势是简单直白,在读操作远远大于写操作的时候没有什么问题,不过其中的lock contention点我们也必须心里有数:整个读操作都是带锁访问的;更新操作是赋值操作,在临界区就有一个析构和拷贝的成本(用智能指针能够优化掉)。
  然后,这些天又再次细细品味陈硕的《Linux多线程服务端编程》,在同步读写共享资源的时候,文中强烈建议使用互斥锁来代替读写锁,理由如下:
  a. 一个比较容易犯的错误是在持有读锁的情况下无意间修改了共享数据,导致的结果就是数据没有真正的被保护,即使开始不会犯这么糊涂,但是代码经过几首修改后就难以保证了,除非操作本身极为简单;
  b. 读写所不见得比mutex高效,因为读写锁需要维护reader的计数,mutex通常也是很高效的,特别在临界区比较小的情况下,而现代的操作系统mutex都采用futex(fast userspace mutex)实现,绝大多数情况下都可以在用户空间完成互斥保护的逻辑,很少情况才会执行系统调用来仲裁互斥结果,因此mutex的实现大多性能不俗;
  c. writer锁会阻塞后续的reader锁,导致读请求被延迟,如果应用需求对读响应比较的敏感,则此时需要额外注意;
  恩,不过开始我还不以为意,因为读写锁之所以被提出来并广为实现和使用,那么存在则必定是有其合理的理由,而且从上面的使用情况很常见也没有遇到什么幺蛾子。现在再仔细看看他的用例,觉得他所提倡的使用智能指针来持有共享资源的话,配合使用互斥锁来进行读写保护是完全可行的,因为此时互斥的仅仅是智能指针的访问,临界区已经小的微乎其微了。
  首先,我们申请如下的成员:

1
2
3
mutable boost::mutex lock_;
typedef std::map<std::string, channel_health_t> HealthMap;
boost::shared_ptr<HealthMap> cached_health_ptr_;

  在读的时候,原始智能指针的计数递增,此后所有的读操作都通过智能指针访问原始数据,此处临界区的消耗就是智能指针的拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool PbiChannelRoute::is_channel_health(std::string channel_name) const {
boost::shared_ptr<HealthMap> health_ptr;
{
boost::unique_lock<boost::mutex> lock(lock_);
health_ptr = cached_health_ptr_;
}
// 无锁访问共享数据
auto iter = health_ptr->find(channel_name);
if ( iter != health_ptr->cend()){
return it->second.is_health;
}
return true;
}

  对于更新操作,我们首先查看共享数据是不是有其他的读者(智能指针的引用计数):如果没有其他读者,就直接修改原始数据,反正这边是一直持有了互斥锁的;如果有其他的读者,我们就根据共享数据生成一个新的拷贝,然后交换新对象和共享对象(智能指针的swap是很高效的),此后我们就可以在新数据基础上做修改了,而原来的数据还被其他读者所持有,他们仍然安全的访问他们持有的对象,只不过此处的更改他们永远也看不到了。

1
2
3
4
5
6
7
8
9
10
11
12
13
void PbiChannelRoute::update_channel_health(const std::string& key) {
...
{
boost::unique_lock<boost::mutex> lock(lock_);
if(!cached_health_ptr_.unique()) {
boost::shared_ptr<HealthMap> health_ptr(new HealthMap(*cached_health_ptr_));
cached_health_ptr_.swap(health_ptr);
}
assert(cached_health_ptr_.unique());
(*cached_health_ptr_)[key] = ...;
}
...
}

  而对于整体数据的更新,这个临界区可以做的更小。

1
2
3
4
5
6
7
8
9
10
11
void PbiChannelRoute::update_channel_health() {
...
{
boost::shared_ptr<HealthMap> health_ptr = ... ;
if(health_ptr) {
boost::unique_lock<boost::mutex> lock(lock_);
cached_health_ptr_.swap(health_ptr); // swap, not assign
}
}
...
}

  所以上面的使用方式看来,可以说任何时候读操作都不会被阻塞,只不过他们访问的数据可能会“旧一些”,即新数据更新完之后,其他访问者还可能持有原先的数据副本。其附带的一个好处是旧副本的析构动作被丢到了临界区之外执行了!

  具体的效率我也没有去测试验证,所以陈硕所言读写锁往往造成提高性能的错觉也不敢妄加评判,但是至少上述情况给出了读写共享数据保护的另外一种机制,值得去学习和玩味。

参考