数组的不同元素上的多个锁

multiple locks on different elements of an array

本文关键字:元素 数组      更新时间:2023-10-16

如果我有8个线程,并且一个数组中有1000000000个元素,那么我可以有1000000000次倍数,其中索引表示数组中被锁定和写入的元素。然而,这对我来说相当浪费,并且需要大量内存。

有没有一种方法可以让我只能使用8个静音并具有相同的功能?

在这里大声思考。。。并且不确定这会有多有效,但是:

您可以创建锁定某些索引的方法:

vector<int> mutexed_slots;
std::mutex mtx;
bool lock_element(int index) 
{
std::lock_guard<std::mutex> lock(mtx);
// Check if item is in the mutexed list
if ( !std::find(mutexed_slots.begin(), mutexed_slots.end(), index) != vector.end() ) 
{
// If its not then add it - now that array value is safe from other threads
mutexed_slots.emplace_back(index);
return true;
}
return false;
}
void unlock_element(int index) 
{
std::lock_guard<std::mutex> lock(mtx);
// No need to check because you will only unlock the element that you accessed (unless you are a very naughty boy indeed)
vec.erase(vec.begin() + index);
}

注意:这是一个想法的开始,所以现在不要太用力!它也是未经测试的伪代码。这并不是一个最终的答案,而是一个起点。请添加评论以改进或暗示这是/不合理的。

进一步要点:

  • 可能有更高效的STL可供使用
  • 您可能会将所有这些与数据一起封装在一个类中
  • 您需要循环使用lock_element(),直到它返回true——同样,目前还不太好。这一机制可以改进
  • 每个线程都需要记住他们目前正在处理哪个索引,这样他们就只能解锁那个特定的索引——同样,这可以在类中进行更多的集成,以确保这种行为

但作为一个概念——可行吗?我想,如果你需要非常快速的访问(也许你确实需要),这可能不会那么有效,你想吗?

更新

如果每个线程/工作线程在mutexed_slots中"注册"自己的条目,这可能会变得更加高效。然后,向量中就没有push_back/remove了(除了开始/结束处)。因此,每个线程只设置它锁定的索引——如果它什么都没有锁定,那么它只设置为-1(或诸如此类)。我认为还有更多这样的效率改进需要改进。再一次,一个完整的类为您完成所有这些将是实现它的方法


测试/结果

我为此实现了一个测试程序,只是因为我非常喜欢这种东西。我的实现在这里

我认为这是一个公开的github回购,所以欢迎你看一看。但我把结果发布在了顶级自述上(所以滚动一下可以看到它们)。我实施了一些改进,例如:

  • 运行时没有对保护阵列的插入/删除
  • 不需要lock_guard来进行"解锁",因为我不依赖std::atomic索引。

    以下是我的摘要打印件:

摘要:

当工作量为1ms(执行每个操作所需的时间)时,完成的工作量为:

  • 9808受保护
  • 8117用于正常

    注意这些值各不相同,有时正常值更高,似乎没有明确的赢家。

当工作负载为0ms(基本上增加了几个计数器)时,完成的工作量为:

  • 9791264受保护
  • 29307829表示正常

因此,在这里您可以看到,使用互斥保护会将工作速度降低约三分之一(1/3)。这个比率在两次测试之间是一致的。

我还对一名员工进行了同样的测试,大致相同的比例成立。然而,当我将数组缩小(约1000个元素)时,当工作负载为1ms时,所做的工作量仍然大致相同。但当工作量很轻时,我得到的结果是:

  • 5621311
  • 39157931

    这大约慢了7倍。

结论

  • 数组越大,发生的冲突就越少,性能越好
  • 工作负载(每个项目)越长,则使用保护机制的差异就越不明显

锁定似乎通常只是增加一个开销,这个开销比增加几个计数器慢2-3倍。这可能是由于实际碰撞造成的,因为(从结果来看)记录的最长锁定时间是40毫秒,但这是在工作时间非常快的时候,因此发生了许多碰撞(每次碰撞成功锁定约8次)。

这取决于访问模式,你有有效划分工作的方法吗?基本上,您可以将数组划分为8个块(或者尽可能多),并用互斥体覆盖每个部分,但如果访问模式是随机的,那么仍然会有很多冲突。

您的系统是否支持TSX?这将是一个经典的例子,只有一个全局锁,除非发生实际冲突,否则让线程忽略它。

您可以编写一个类,在特定索引需要时动态创建锁,std::optional将对此有所帮助(C++17代码先行):

class IndexLocker {
public:
explicit IndexLocker(size_t size) : index_locks_(size) {}
std::mutex& get_lock(size_t i) {
if (std::lock_guard guard(instance_lock_); index_locks_[i] == std::nullopt) {
index_locks_[i].emplace();
}
return *index_locks_[i];
}
private:
std::vector<std::optional<std::mutex>> index_locks_;
std::mutex instance_lock_;
};

您也可以使用std::unique_ptr来最小化堆栈空间,但保持相同的语义:

class IndexLocker {
public:
explicit IndexLocker(size_t size) : index_locks_(size) {}
std::mutex& get_lock(size_t i) {
if (std::lock_guard guard(instance_lock_); index_locks_[i] == nullptr) {
index_locks_[i] = std::make_unique<std::mutex>();
}
return *index_locks_[i];
}
private:
std::vector<std::unique_ptr<std::mutex>> index_locks_;
std::mutex instance_lock_;
};

使用此类并不一定意味着需要创建所有1000000个元素。您可以使用模运算将locker视为互斥对象的"哈希表":

constexpr size_t kLockLimit = 8;
IndexLocker index_locker(kLockLimit);
auto thread_code = [&](size_t i) {
std::lock_guard guard(index_locker.get_lock(i % kLockLimit));
// Do work with lock.
};

值得一提的是,"哈希表"方法使死锁变得非常容易(例如,get_lock(0)后面跟着get_lock(16))。然而,如果每个线程一次只处理一个元素,那么这应该不是问题。

细粒度锁定还有其他权衡。原子操作是昂贵的,因此锁定每个元素的并行算法可能比顺序版本花费更长的时间。

如何有效锁定取决于。数组元素是否依赖于数组中的其他元素?你大部分时间都在读书吗?主要是写作?

我不想把数组分成8部分,因为这会导致等待的可能性很高(访问是随机的)。数组是我将编写的一个类,它将被多重Golomb编码价值观

我不认为拥有8个互斥是实现这一目标的方法。如果给定的锁保护一个数组段,那么在并行执行过程中,如果不引入竞争条件(使互斥对象变得毫无意义),就无法将其切换为保护另一个段。

数组项目小吗?如果可以将它们减少到8个字节,则可以用alignas(8)声明类并实例化std::atomic<YourClass>对象。(大小取决于体系结构。验证is_lock_free()是否返回true。)这可能为无锁算法开辟了可能性。在这里,危险指示器的变体似乎很有用。这很复杂,所以如果时间有限,最好研究其他并行方法。