65.9K
CodeProject 正在变化。 阅读更多。
Home

线程安全的 std::map,速度媲美无锁 Map。

starIconstarIconstarIconstarIconstarIcon

5.00/5 (19投票s)

2017年4月24日

CPOL

22分钟阅读

viewsIcon

65231

downloadIcon

1255

线程安全指针和无争用共享互斥体的用法和测试示例。

三篇相关文章

  1. 我们让任何对象都线程安全
  2. 我们让 std::shared_mutex 速度提升 10 倍
  3. 线程安全的 std::map,兼具无锁 map 的速度

引言

线程安全指针和无竞争共享互斥锁的使用和测试示例

在本文中,我们将展示我们开发的线程安全指针的额外优化、使用示例和测试,该指针使用优化的共享互斥锁 contfree_safe_ptr<T> - 这等同于 safe_ptr<T, contention_free_shared_mutex<>>

最后,我们将展示我们的线程安全指针测试与 libCDS 中一些最佳无锁算法在 Intel Core i5/i7、Xeon、2 x Xeon 处理器上的对比图。

您可以通过以下链接找到 libCDS:https://github.com/khizmax/libcds

本文中的所有基准测试都使用了 libCDS 的此提交版本 https://github.com/khizmax/libcds/tree/5e2a9854b0a8625183818eb8ad91e5511fed5898

背景

不同粒度的锁

首先,我们将以处理表(结构数组)为例,展示如何最佳地使用共享互斥锁。让我们借鉴工业 RDBMS 的经验。例如,MSSQL RDBMS 使用不同粒度的锁:锁定一行或多行、一个页面、一个范围、表的一个分区、一个索引、整个表、整个数据库。 锁粒度和层次结构

确实,如果我们要长时间处理一行,并且在此期间不希望该行被其他线程更改,那么没有必要一直锁定整个表——只需锁定一行就足够了。

  1. 使用共享锁锁定整个表
  2. 查找所需的一行或多行
  3. 然后锁定找到的行
  4. 解锁表
  5. 并处理锁定的行

然后其他线程可以并行处理其余的行。

到目前为止,我们只使用了表级别的锁,即我们锁定了一个或多个表。

或者表达式中使用的所有表都被自动锁定,直到它完全完成。

(*safe_map_1)["apple"].first = "fruit";   // locked Table-1 (safe_map_1) 
// unlocked Table-1

safe_map_1->at("apple").second = 
      safe_map_1->at("apple").second * 2; // locked Table-1 (safe_map_1)
// unlocked Table-1

safe_map_1->at("apple").second = 
  safe_map_2->at("apple").second*2;       // locked Table-1(safe_map_1) and Table-2(safe_map_2)
// unlocked Table-1 and Table-2

在其他情况下,我们通过使用 RAII 锁对象手动锁定了一个或多个表,直到这些锁的块作用域过期(直到它们被销毁)。

{
 std::lock_guard< decltype(safe_map_1) > lock(safe_map_1); // locked Table-1 (safe_map_1)

 (*safe_map_1)["apple"].first = "fruit"; 

 safe_map_1->at("apple").second = 
     safe_map_1->at("apple").second * 2;

 // unlocked Table-1
}

{	
 lock_timed_any_infinity lock_all(safe_map_1, safe_map_2); 

 // locked Table-1(safe_map_1) and Table-2(safe_map_2)

 safe_map_1->at("apple").second = 
  safe_map_2->at("apple").second*2;  // locked Table-1(safe_map_1) and Table-2(safe_map_2)

 safe_map_1->at("potato").second = 
  safe_map_2->at("potato").second*2; // locked Table-1(safe_map_1) and Table-2(safe_map_2)

 // unlocked Table-1 and Table-2
}

我们来考虑一个示例,在该示例中,我们随机选择一个要插入的索引,然后随机选择四种操作(insertdeleteupdateread)中的一种,并对类型为 contfree_safe_ptr<std::map> 的线程安全对象执行该操作。

示例:[37] http://coliru.stacked-crooked.com/a/5b68b65bf2c5abeb

在这种情况下,我们将对表进行以下锁定

  • Insert - 排他锁
  • Delete - 排他锁
  • Update - 排他锁
  • Read - 共享锁

对于 UpdateRead 操作,我们执行以下操作

  1. 锁定整个表(UpdatexlockReadslock
  2. 搜索必要的行,读取或更改它
  3. 解锁表

我们示例 1 中一次迭代的代码

        int const rnd_index = index_distribution(generator); // 0 - container_size
        int const num_op = operation_distribution(generator);// insert_op, update_op, 
                                                             // delete_op, read_op

        switch (num_op) {
        case insert_op: {
            safe_map->emplace(rnd_index,(field_t(rnd_index, rnd_index))); // insert with 
                                                                          // X-lock on Table
        }
        break;
        case delete_op: {
            size_t erased_elements = safe_map->erase(rnd_index);    // erase with X-lock 
                                                                    // on Table
        }
        break;
        case update_op: {
            auto x_safe_map = xlock_safe_ptr(safe_map);  // X-lock on Table
            auto it = x_safe_map->find(rnd_index);
            if (it != x_safe_map->cend()) {
                it->second.money += rnd_index;           // still X-lock on Table 
                                                         // (must necessarily be)
            }
        }
        break;
        case read_op: {
            auto s_safe_map = slock_safe_ptr(safe_map);  // S-lock on Table
            auto it = s_safe_map->find(rnd_index);
            if (it != s_safe_map->cend()) {
                volatile int money = it->second.money;   // still S-lock on Table 
                                                         // (must necessarily be)
                // volatile here only to avoid optimization for unused money-variable
            }
        }
        break;
        default: std::cout << "\n wrong way! \n";  break;
        }

现在,我们将执行以下操作:在 Update 操作期间,通过读锁(共享)而不是通过更改锁(排他)来锁定表。这将大大加快使用我们之前开发的“无写竞争共享互斥锁”时的 Update 操作。

在这种情况下,多个线程可以同时对一个表执行 UpdateRead 操作。例如,一个线程读取一行,另一个线程更改另一行。但是,如果一个线程尝试更改另一个线程正在读取的同一行,那么为了避免数据竞争,我们必须在读取和更改该行时锁定该行本身。

示例:[38] http://coliru.stacked-crooked.com/a/89f5ebd2d96296d3

现在对于 UpdateRead 操作,我们执行以下操作

  1. 使用共享锁锁定整个表
  2. 查找所需的一行或多行
  3. 然后锁定找到的行(UpdatexlockReadslock
  4. 并处理锁定的行(X/S 锁)和锁定的表(S 锁)
  5. 解锁行
  6. 解锁表

差异(我们已更改的)

我们示例 2 中一次迭代的代码

        int const rnd_index = index_distribution(generator); // 0 - container_size
        int const num_op = operation_distribution(generator);// insert_op, update_op, 
                                                             // delete_op, read_op

        switch (num_op) {
        case insert_op: {
            safe_map->emplace(rnd_index, (field_t(rnd_index, rnd_index)));// insert with 
                                                                          // X-lock on Table
        }
        break;
        case delete_op: {
            size_t erased_elements = safe_map->erase(rnd_index);    // erase with X-lock 
                                                                    // on Table
        }
        break;
        case update_op: {
            auto s_safe_map = slock_safe_ptr(safe_map);             // S-lock on Table
            auto it = s_safe_map->find(rnd_index);
            if (it != s_safe_map->cend()) {
                auto x_field = xlock_safe_ptr(it->second);
                x_field->money += rnd_index;   // X-lock on field, still S-lock on Table
                                               // (must necessarily be)
            }
        }
        break;
        case read_op: {
            auto s_safe_map = slock_safe_ptr(safe_map); // S-lock on Table
            auto it = s_safe_map->find(rnd_index);
            if (it != s_safe_map->cend()) {
                auto s_field = slock_safe_ptr(it->second);
                volatile int money = s_field->money;   // S-lock on field, 
                                                       // still S-lock on Table 
                                                       // (must necessarily be)
                // volatile here only to avoid optimization for unused money-variable
            }
        }
        break;
        default: std::cout << "\n wrong way! \n";  break;
        }

在这里,为了线程安全地处理行,我们使用了 safe_objsafe_obj<T> 内部有一个 T 类型的对象,而不是像 safe_ptr<T> 那样是指向它的指针。因此,使用 safe_obj 时,您不需要像 safe_ptr 中那样单独为对象本身分配内存并更改原子引用计数。因此,使用 safe_obj 执行 InsertDelete 操作比使用 safe_ptr 快得多。

值得注意的是,在复制 safe_obj 时,复制的不是指向对象的指针,而是对象本身。在这里,源 safe_obj 和目标 safe_obj 都会被预先锁定。

注意:严格来说,我们没有锁定整行,而是锁定了该行的所有字段,除了我们用于搜索的行索引。因此,我们将我们的对象称为“field”,而不是“row”。此外,为了强调这种方式我们不仅可以锁定一行,甚至可以锁定一行中的单个字段,如果我们将其放置在单独的 safe_obj 对象中。这将允许不同的线程锁定不同的字段并并行处理它们。

现在我们对不同的操作使用以下锁

这个例子对于大量的短期操作非常快。但是,在更改或读取行(字段)时,我们仍然持有表的读锁(共享)。如果我们在表的行上有罕见但非常长的操作,那么整个表的锁将一直保持很长时间。

但是,如果根据您的任务逻辑,一行可能被一个线程删除,而另一个线程正在读取或更改同一行,这无关紧要,那么只需在查找行的时间内锁定表即可。为了避免访问已释放的内存(当另一个线程删除该行时),我们需要使用 std::shared_ptr<T> - 带有原子线程安全引用计数的指针。在这种情况下,内存只有在没有线程指向该行时才会被释放。

我们将使用 safe_ptr 而不是 safe_obj 来保护行。这将允许我们复制指向行的指针,并使用 safe_ptr 中包含的 std::shared_ptr<T> 中的线程安全引用计数。

示例:[39] http://coliru.stacked-crooked.com/a/f2a051abfbfd2716

现在,对于 UpdateRead 操作,我们执行以下操作

  1. 使用共享锁锁定整个表
  2. 查找所需的一行或多行
  3. 然后锁定找到的行(UpdatexlockReadslock
  4. 解锁表
  5. 并根据需要处理锁定的行(X/S 锁)
  6. 解锁行

差异(我们已更改的)

示例 3

        int const rnd_index = index_distribution(generator);     // 0 - container_size
        int const num_op = operation_distribution(generator);    // insert_op, update_op, 
                                                                 // delete_op, read_op
        safe_ptr_field_t safe_nullptr;

        switch (num_op) {
        case insert_op: {
            safe_map->emplace(rnd_index, (field_t(rnd_index, rnd_index))); //insert with 
                                                                           //X-lock on Table
        }
        break;
        case delete_op: {
            size_t erased_elements = safe_map->erase(rnd_index);    // erase with X-lock 
                                                                    // on Table
        }
        break;
        case update_op: {
            auto pair_result = [&]() {
                auto s_safe_map = slock_safe_ptr(safe_map);         // S-lock on Table
                auto it = s_safe_map->find(rnd_index);
                if (it != s_safe_map->cend()) return std::make_pair(it->second, true);  // found
                else return std::make_pair(safe_nullptr, false);    // null-value
            }();    // unlock Table
            
            if(pair_result.second) {
                auto x_field = xlock_safe_ptr(pair_result.first);   // X-lock on field
                x_field->money += rnd_index;                        // if a long time is read
            }   // unlock field            
        }
        break;
        case read_op: {
            auto pair_result = [&]() {
                auto s_safe_map = slock_safe_ptr(safe_map);         // S-lock on Table
                auto it = s_safe_map->find(rnd_index);
                if (it != s_safe_map->cend()) return std::make_pair(it->second, true);  // found
                else return std::make_pair(safe_nullptr, false);    // null-value
            }();    // unlock Table
            
            if(pair_result.second) {
                auto s_field = slock_safe_ptr(pair_result.first);   // S-lock on field
                volatile int money = s_field->money;                // if a long time is read
                // volatile here only to avoid optimization for unused money-variable
            }   // unlock field
        }
        break;
        default: std::cout << "\n wrong way! \n";  break;
        }

精心设计的多线程程序对共享对象使用短调用。因此,未来我们将使用倒数第二个示例进行短读操作,而不是最后一个。

“环绕执行”习语的缺点

让我们考虑可能的问题并批评我们的代码。

在上一章中,我们考虑了一个相当方便且高性能的例子,通过函数显式指定 UpdateRead 操作的锁定类型:

  • slock_safe_ptr() - 只读
  • xlock_safe_ptr() - 用于读写

在这里,锁一直保持到这些函数返回的 lock_obj 对象的生命周期结束:auto lock_obj = slock_safe_ptr (sf_p);

然而,对于 InsertDelete 操作使用了隐式锁。我们的 safe_ptr<std::map> 对象通过“环绕执行指针”习语自动锁定,并在 InsertDelete 操作终止后立即解锁。

示例:[40] http://coliru.stacked-crooked.com/a/89f5ebd2d96296d3

但是您可能会忘记在 UpdateRead 操作中使用显式锁。在这种情况下,safe_ptr<std::map> 将在搜索操作完成后立即解锁,然后您继续使用

  1. 找到的迭代器,它可能被另一个线程无效
  2. 或找到的元素,它可能被另一个线程删除

为了部分解决这个问题,您可以使用 safe_hide_ptr<>safe_hide_obj<> 来代替 safe_ptr<>safe_obj<> — 它们不使用“环绕执行指针”习语,并且您只能在显式锁定后才能访问类成员。

safe_hide_obj<field_t, spinlock_t=""> field_hide_tmp;
  //safe_obj<field_t, spinlock_t=""> &field_tmp = field_hide_tmp; // conversion denied - 
                                                                  // compile-time error     
        
  //field_hide_tmp->money = 10;                     // access denied - compile-time error
  auto x_field = xlock_safe_ptr(field_hide_tmp);    // locked until x_field is alive
  x_field->money = 10;                              // access granted
</field_t,></field_t,>

示例:[41] http://coliru.stacked-crooked.com/a/d65deacecfe2636b

以前,您可能会犯错误并写出以下——错误代码

auto it = safe_map->find(rnd_index); // X-lock, find(), X-unlock
if (it != s_safe_map->cend()) 
    volatile int money = it->second ->money;  // X-lock, operator=(), X-unlock

但现在,这样的请求无法编译,并且需要显式锁定对象——正确的代码

auto s_safe_map = slock_safe_ptr(safe_map); // S-lock on Table
auto it = s_safe_map->find(rnd_index);
if (it != s_safe_map->cend()) {
    auto s_field = slock_safe_ptr(it->second);
    volatile int money = s_field->money;   // S-lock on field, still S-lock on Table 
    // volatile here only to avoid optimization for unused money-variable
} // S-unlock Table, S-unlock field

然而,您仍然面临将锁用作临时对象的危险——不正确

auto it = slock_safe_ptr(safe_map)->find(rnd_index); // S-lock, find(), S-unlock
if (it != s_safe_map->cend()) {
    volatile int money = slock_safe_ptr(it->second)->money;   // S-lock, =, S-unlock
}

您有选择

  • 使用 safe_ptrsafe_obj 以便能够显式或自动(环绕执行习语)锁定您的对象
  • 或者使用 safe_hide_ptrsafe_hide_obj,只保留显式锁定对象的能力

由您决定如何选择

  • 使用方便的自动锁定(环绕执行习语)
  • 或通过显式锁定对象略微减少出错的可能性

此外,以下函数计划被纳入 C++17 标准,用于 std::map<>

  • insert_or_assign() - 如果存在元素,则赋值;如果不存在,则插入
  • try_emplace() - 如果元素不存在,则创建它
  • merge() - 将两个 map 合并为一个
  • extract() - 获取元素并将其从 map 中移除

引入此类函数允许在不使用迭代器的情况下执行常用复合操作——在这种情况下,使用“环绕执行”习语将始终保证这些操作的线程安全性。总的来说,避免对所有容器(除了数组 std::arraystd::vector)使用迭代器,对于开发多线程程序大有裨益。您使用迭代器的频率越低,访问被任一线程无效的迭代器的机会就越少。

但无论您选择什么:使用“环绕执行”习语,为 safe_hide_ptr 使用显式锁,放弃它们并使用标准的 std::mutex 锁...或者编写自己的无锁算法——您总是有很多机会犯错误。

通过分区表提高性能

让我们回顾一下工业关系数据库的经验。例如,在 RDBMS 中,可以使用表分区来提高性能。在这种情况下,您可以只锁定使用的分区,而不是整个表:https://technet.microsoft.com/en-us/library/ms187504(v=sql.105).aspx

尽管在 RDBMS 中,DeleteInsert 操作通常不会锁定整个表(对于 Delete 操作总是如此),但对于 Insert 操作,可以非常快速地将数据加载到表中,其强制条件是独占锁定表。

  • MS SQL (dbcc traceon (610, -1)): INSERT INTO sales_hist WITH (TABLOCKX)
  • Oracle: INSERT /*+ APPEND */ INTO sales_hist

https://docs.oracle.com/cd/E18283_01/server.112/e17120/tables004.htm#i1009887

Direct-Path INSERT 的锁定注意事项

在直接路径 INSERT 期间,数据库对表(或分区表的所有分区)获取独占锁。因此,用户无法对表执行任何并发的 insertupdatedelete 操作,也不允许并发的索引创建和构建操作。但是,支持并发查询,但查询只会返回 insert 操作之前的信息。

由于我们的任务是创建最快的多线程容器,我们也在 Insert / Delete 操作中完全锁定了容器。但现在让我们尝试只锁定容器的一部分。

让我们尝试实现我们自己的分区有序关联数组 partitioned_map,看看性能会提高多少。我们只锁定当前需要的那个部分。

根据上下文,它是 std::map< safe_ptr<std::map<>> >

这里,第一个 std::map 将是常量,并将包含分区(子表)。

这将是一个非常简化的示例,其中分区的数量在构造函数中指定,并且不再更改。

每个分区都是一个线程安全的关联数组 safe_ptr<std::map<>>

为了提供最大的性能,分区的数量和范围应使得访问每个分区的概率相同。如果您的键范围从 0 到 1000000,并且对范围开头进行 read/update/insert/delete 调用的概率大于对范围末尾的概率,则键值较小的分区数量应更多,其范围应更小。例如,3 个分区:[0 - 100000],[100001 - 400000],[400001 - 1000000]。

但在我们的示例中,我们将假设查询键具有均匀分布。

分区范围可以通过两种方式分配

  • safe_map_partitioned_t<std::string, int> safe_map { "a", "f", "k", "p", "u" };

// 分配分区限制

  • safe_map_partitioned_t<int, int> (0, 100000, 10000);

// 分配值限制 0 – 100000 和每个分区的步长 10000

如果访问容器时,键超出了分区的限制,查询将指向最近的分区,即程序将正常工作。

示例:[42] http://coliru.stacked-crooked.com/a/fc148b08eb4b0580

此外,为了获得最大性能,需要使用我们之前在 safe_ptr<> 内部实现的“无竞争共享互斥锁”。从字面意义上讲,这是:std::map<contfree_safe_ptr<std::map<>> >

让我们从上一个示例中获取代码,并从上一章中添加 contfree_safe_ptr 代码。

替换:safe_map_partitioned_t<std::string, std::pair<std::string, int>>

替换为:safe_map_partitioned_t<std::string, std::pair<std::string, int>,contfree_safe_ptr>

示例:[43] http://coliru.stacked-crooked.com/a/23a1f7a3982063a1

我们创建这个 safe_map_partitioned_t<> 类“只是为了好玩”,也就是说,不建议在实际程序中使用它。我们只是展示了一个基于 contfree_safe_ptr<> 指针和 contention_free_shared_mutex<> 锁实现您自己的算法的示例。

Using the Code

如何使用

首先,您应该下载文件 - safe_ptr.h

并将其包含到您的 cpp 文件中:#include "safe_ptr.h"

作为最佳用法,我们将停留在上面显示的示例 2 — 它既简单又高性能。

struct field_t { 
    int money, time; 
    field_t(int m, int t) : money(m), time(t) {} 
    field_t() : money(0), time(0) {} 
};
typedef safe_obj<field_t, spinlock_t> safe_obj_field_t;

// thread-safe ordered std::map by using execute around pointer idiom 
// with contention-free shared-lock
contfree_safe_ptr< std::map<int, safe_obj_field_t > > safe_map_contfree;

bool success_op;
switch (num_op) {
    case insert_op:
        slock_safe_ptr(test_map)->find(rnd_index);  // find for pre-cache to L1 
                                                    // with temporary S-lock
        test_map->emplace(rnd_index, field_t(rnd_index, rnd_index));
        break;
    case delete_op:
        slock_safe_ptr(test_map)->find(rnd_index);  // find for pre-cache to L1 
                                                    // with temporary S-lock
        success_op = test_map->erase(rnd_index);
        break;
    case read_op: {
        auto s_safe_map = slock_safe_ptr(test_map); // S-lock on Table (must necessarily be)
        auto it = s_safe_map->find(rnd_index);
        if (it != s_safe_map->cend()) {
            auto x_field = xlock_safe_ptr(it->second);
            volatile int money = x_field->money;    // get value
            x_field->money += 10;                   // update value
        }
    }
        break;
    default: std::cout << "\n wrong way! \n";  break;
}

插入和删除 - map 的修改: 由于我们的共享锁 slock_safe_ptr() 是最快的,因此在修改(insert_opdelete_op)之前,我们使用快速共享锁查找我们需要删除的项目或插入的最近项目,该锁在 find() 之后立即解锁。我们自己不使用找到的项目。但是,这会告诉 CPU 哪些数据在后续的 map 修改中最好缓存到 L1。然后我们执行 test_map->emplace() 进行插入或 test_map->erase() 进行删除,它会自动调用排他锁。Insert/delete 发生得很快,因为所有数据都已缓存到 CPU-L1 中,我们不需要将共享锁升级为排他锁(这会大大增加死锁的可能性 - 冻结程序)。

读取和更新 - 读取或修改项:我们所说的读取(read_op)是指修改 map 中的一个项(map 的一行)。因此,在读取之前,我们对 map 进行共享锁 slock_safe_ptr(),以便其他线程无法删除或替换此项。并在存在对象 s_safe_map 时持有共享锁,然后我们仅对找到的一个项施加排他锁 xlock_safe_ptr(),然后读取此项并进行更改。之后,当离开块作用域 {} 时,x_field 对象首先被销毁,并且项的排他锁被移除,然后销毁下一个对象 s_safe_map,并且 map 的共享锁被移除。

编译器允许您对 test_map 执行任何操作——读写和调用 test_map 的任何方法——在这种情况下,会自动应用排他锁。

但编译器只允许您读取对象并调用 slock_safe_ptr(test_map)const 方法,例如 slock_safe_ptr(test_map)->find();,但不允许 slock_safe_ptr(test_map)->emplace();。对于 some_ptr(其中 auto const& some_ptr = test_map;),也允许同样的操作,例如 some_ptr->find() ——在这种情况下,会自动应用共享锁。

所有这些,甚至更多——在第一篇文章中进行了详细而严谨的探讨。

已实现的 safe_ptr 的性能比较

让我们总结一下中期结果。让我们看看由于我们的优化,性能提升的水平。

这里是性能图表——每秒百万次操作 (MOps) 的数量,修改操作的百分比范围从 0% 到 90%。在修改过程中,将以相等的概率执行三项操作之一:insertdeleteupdate(尽管 Update 操作本身不会改变 std::map 树,只会改变其中的一行)。例如,15% 的修改涉及以下操作:5% insert、5% delete、5% update、85% read。编译器:g++ 4.9.2 (Linux Ubuntu) x86_64

要在 Linux 上使用 Clang++ 3.8.0 编译器进行编译,您需要修改 Makefile。

这是一个在单个服务器 CPU Intel Xeon E5-2660 v3 2.6 GHz 上对 16 个线程进行测试的示例。首先,我们对橙色线感兴趣:safe<map, contfree>&rowlock

如果您的同一主板上有多个 Xeon CPU,您可以通过以下方式运行测试来重现此结果:

numactl --localalloc --cpunodebind=0 ./benchmark 16

16 个线程的性能,MOps - 每秒百万次操作

结论

  • 有趣的是,我们 contfree_safe_ptr<map> 中的共享无竞争锁比 safe_ptr <map, std::shared_mutex> 中的标准 std::shared_mutex 快得多。
  • 同样有趣的是,从 15% 的更改开始,std::mutexstd::shared_mutex 更快(即 safe_ptr<map, std::mutex>safe_ptr<map, std::shared_mutex> 更快)。
  • 同样奇怪的是,从 30% 的更改开始,std:map-1thread 的性能高于 contfree_safe_ptr<map>&rowlock 的性能。但实际任务不仅仅包括关联数组的操作和线程同步。通常,线程间数据交换只占任务的一小部分,而主要部分可以很好地并行化到多个核心。
  • 分区的关联数组 safe_map_partitioned<,,contfree_safe_ptr> 表现出良好的性能,但它应该只作为“Just-for-fun”使用——如果所需的分区数量及其范围是预先知道的。
  • 对于 15% 的修改,我们的共享互斥锁(作为 «contfree_safe_ptr<map> & rowlock» 的一部分)表现出 8.37 MOps 的性能,比标准 std::shared_mutex(作为 «safe_ptr<map, std::shared_mutex>» 的一部分)的 0.74 MOps 快 10 倍

我们的锁“无竞争共享互斥锁”适用于任意数量的线程:对于前 70 个线程,它作为无竞争锁工作,对于后续线程,它作为排他锁工作。从图表中可以看出,即使是排他锁 «std::mutex» 对于 15% 的修改也比 «std::shared_mutex» 快。无竞争锁的线程数 70 由模板参数指定,并且可以更改。

此外,我们还将展示中位延迟图(以微秒为单位),即一半请求的延迟低于此值。

在测试代码 main.cpp 中,您应该设置:const bool measure_latency = true;

16 个线程的中位延迟,微秒
  • 有趣的是,std::mapstd::mutex 的范围与 safe_ptr<map, std::mutex> 相似。也就是说,通常使用 safe_ptr<> 不会产生任何额外的开销成本,也不会降低性能。
  • 此外,有趣的是,从 60% 的变化开始,contfree_safe_ptr<map>&rowlock 表现出比 contfree_safe_ptr<map> 更高的延迟。但从前一个图中,我们了解到,对于任何百分比的修改操作,contfree_safe_ptr<map>&rowlock 的总体性能仍然更高。

contfree_safe_ptr<std::map> 与 CDS-lib 中容器在不同桌面 CPU 上的性能比较。

使用所有核心在不同桌面 CPU 上的性能比较。

性能,MOps - 每秒百万次操作
  • 有趣的是,对于笔记本电脑和台式机,contfree_safe_ptr<map> 在 CDS-lib 的无锁 map 容器中显示出最高的性能。
  • 我们“仅供娱乐”版本的关联容器,分区数量固定为 safe_map_partitioned<,,contfree_safe_ptr>,平均性能提升了 1.7 倍。

contfree_safe_ptr<std::map> 与 CDS-lib 中容器在服务器 CPU 上的性能比较。

在单个服务器 CPU 上使用不同数量线程的不同多线程关联数组的性能比较。

要在 Linux 上使用 Clang++ 3.8.0 编译器进行编译,您需要修改 Makefile。

如果您的同一主板上有多个 Xeon CPU,您可以通过以下方式运行测试来重现此结果:

numactl --localalloc --cpunodebind=0 ./benchmark 16

1 - 16 个线程的性能,MOps - 每秒百万次操作
  • 如您所见,如果您在单个 CPU 上使用 16 个或更多并行线程,或者如果您使用两个以上的 CPU,那么 CDS-lib 中的无锁容器会比 contfree_safe_ptr<map> 表现出更好的性能。也就是说,在强竞争的情况下,无锁容器更好。但是,即使您有超过 16 个线程,但一次访问容器的线程不超过 8 个,那么 contfree_safe_ptr<map> 仍然会比无锁容器更快。
  • 此外,具有固定数量 safe_map_partitioned<,,contfree_safe_ptr> 分区的“Just-for-fun”容器在任何线程数下都显示出更好的性能,但它只能在预先知道所需分区数量和范围的情况下使用。

中位延迟是指 50% 的查询完成时间低于该值的时间。

在测试代码 main.cpp 中,您应该设置:const bool measure_latency = true;

1 - 16 个线程的中位延迟,微秒

对于 contfree_safe_ptr<map>,中位延迟也几乎等于无锁容器在最多 8 个同时竞争线程下的延迟,但在 16 个竞争线程的情况下会更差。

实际应用程序性能

实际应用程序不仅仅包含线程之间的数据交换。实际应用程序的主要工作在每个线程中单独执行,并且只有偶尔才发生线程之间的数据交换。

为了模拟应用程序的实际工作,我们在每次访问线程安全容器之间添加一个不可优化的循环 for(volatile int i = 0; i <9000; ++ i);。在测试开始时,我们将执行此循环 100,000 次,并测量此循环的平均执行时间。在 Intel Xeon E5-2686 v4 2.3 GHz CPU 上,这种实际工作模拟大约需要 20.5 微秒。

要在 Linux 上使用 Clang++ 3.8.0 编译器进行编译,您需要修改 Makefile。

我们将在双处理器服务器上执行测试:2 x Intel Xeon E5-2686 v4 2.3 GHz (Broadwell),总核心数:36 个物理核心和 72 个逻辑核心(超线程)。

要在 Linux 上构建和测试,请执行

cd libcds
make
cd ..
make
./benchmark

1 - 64 个线程的性能,MOps - 每秒百万次操作
  • 使用标准互斥锁——std::mutexstd::shared_mutex——来保护 std::map,其性能在最多 16 个线程时接近无锁 map 容器。但随后,std::mutex & std::mapstd::shared_mutex & std::map 的性能开始落后,并在 32 个线程之后开始下降。
  • 我们优化的线程安全指针 contfree_safe_ptr<std::map<>> 的性能几乎与 libCDS 库中无锁 map 容器的性能相等(适用于 1 到 64 个线程的任何数量)。这在实际应用程序中成立,前提是线程间的交换平均每 20 微秒或更少发生一次。

要测量中位延迟——在测试代码 main.cpp 中,您应该设置

const bool measure_latency = true;

只需在 Linux 上启动基准测试:./benchmark

1 - 64 个线程的中位延迟,微秒
  • 有趣的是,在 64 个线程下,std::mutex 的性能和中位延迟都高于 std::shared_mutex
  • 我们优化的线程安全指针 contfree_safe_ptr<std::map<>> 在 1 到 64 个线程的任何数量下,具有与 libCDS 中的无锁 map 容器相同的延迟。这也适用于实际应用程序中线程之间的交换每 20 微秒或更少发生一次的情况。
  • 对于 64 个线程,延迟为 30 微秒,其中 20 微秒是应用程序延迟,10 微秒是多线程延迟。因此,即使多线程延迟占总延迟的 30%,我们的线程安全智能指针 contfree_safe_ptr<T> 也具有与 libCDS 的无锁 map 容器相同的性能(MOps 和中位延迟)。

libCDS 中更简单的无锁和无等待容器(队列、栈……)的延迟明显低于其基于任何锁的实现。

关注点

这些文章涵盖的问题

在这些文章中,我们详细探讨了 C++ 语言结构在单个线程中通过“环绕执行指针”习语执行的顺序。我们还以无竞争共享互斥锁为例,探讨了多线程程序中读写操作的执行顺序。我们提供了 libCDS 中无锁算法和我们开发的基于锁的算法的高性能使用示例。

我们工作的成果 - safe_ptr.h

结论

  1. 如果线程对同一容器存在强竞争(即,一次有超过 8 个线程访问容器),那么最好的解决方案是使用 libCDS 库中的容器:https://github.com/khizmax/libcds
  2. 如果 libCDS 具有您需要的线程安全容器,那么就使用它。
  3. 如果您的程序执行其他操作,而不仅仅是线程之间的数据交换,并且线程之间的交换不超过总时间的 30%,那么即使您使用几十个线程,我们的 contfree_safe_ptr<> 指针也比 libCDS 中的 map 容器更快。
  4. 如果您使用自己的容器或数据结构(不在 libCDS 中)进行线程间数据交换,那么 contfree_safe_ptr<T> 将是一个快速简便的解决方案。这比为您的自定义数据结构编写无锁方法的实现要容易得多。

如果您想了解如何构建包含数百个 CPU、GPU 和 FPGA 的 HPC 系统,您可以查阅这篇文章:http://www.alexeyab.com/2017/04/the-fastest-interconnect-for-hundreds.html

您希望在 Boost 库中看到本文中分析的算法的修改形式吗?

历史

  • 2017年4月24日 - 初始版本
  • 2017年5月1日 - 添加了GitHub链接
© . All rights reserved.