Skip to content

Commit 4024d30

Browse files
chandlercdanakj
andauthored
Add a more friendly "latch" synchronization tool (#6372)
The standard `std::latch` is very restrictive in how it can be used, and this makes it hard to easily leverage for simple coordination between a set of dynamically scheduled tasks, where there isn't an interesting synchronizing "merge" or future result. This tool makes it easy to establish a latch, hand out handles to it, and once all are destroyed, take whatever relevant action. Note: this is split out of a larger change that uses it. I can wait until the use case is ready, but seemed nice to review this separately. --------- Co-authored-by: Dana Jansens <[email protected]>
1 parent bc734bb commit 4024d30

File tree

4 files changed

+354
-0
lines changed

4 files changed

+354
-0
lines changed

common/BUILD

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,28 @@ cc_library(
414414
alwayslink = 1,
415415
)
416416

417+
cc_library(
418+
name = "latch",
419+
srcs = ["latch.cpp"],
420+
hdrs = ["latch.h"],
421+
deps = [
422+
":check",
423+
"@llvm-project//llvm:Support",
424+
],
425+
)
426+
427+
cc_test(
428+
name = "latch_test",
429+
size = "small",
430+
srcs = ["latch_test.cpp"],
431+
deps = [
432+
":latch",
433+
"//testing/base:gtest_main",
434+
"@googletest//:gtest",
435+
"@llvm-project//llvm:Support",
436+
],
437+
)
438+
417439
cc_library(
418440
name = "map",
419441
hdrs = ["map.h"],

common/latch.cpp

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Part of the Carbon Language project, under the Apache License v2.0 with LLVM
2+
// Exceptions. See /LICENSE for license information.
3+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
4+
5+
#include "common/latch.h"
6+
7+
#include "common/check.h"
8+
9+
namespace Carbon {
10+
11+
auto Latch::Inc() -> void {
12+
// The increment must be _atomic_ but is _relaxed_.
13+
//
14+
// Increments and decrements can happen concurrently on separate threads, so
15+
// we need to prevent tearing and for there to be a total ordering of stores
16+
// to this atomic.
17+
//
18+
// However we provide no _synchronization_ of the increment with any other
19+
// operations. Instead, the caller must provide some extrinsic happens-before
20+
// between its call to `Inc` and its later call to `Dec`. When that call to
21+
// `Dec` synchronizes-with another call to `Dec`, all relaxed stores are
22+
// covered by the resulting inter-thread happens-before relationship.
23+
count_.fetch_add(1, std::memory_order_relaxed);
24+
}
25+
26+
auto Latch::Dec() -> bool {
27+
// The decrement is both an _acquire_ and _release_ operation.
28+
//
29+
// All threads which decrement to a non-zero value need to synchronize-with
30+
// the thread which decrements to a zero value. This means the decrements to
31+
// non-zero values need to have _release_ semantics that are _acquired_ by the
32+
// decrement to zero. Since there is a single decrement operation, it must be
33+
// both _acquire_ and _release_.
34+
//
35+
// Note that this technically provides a stronger guarantee than the contract
36+
// of `Dec` requires -- *all* decrements synchronize with all decrements whose
37+
// value they observe, we only need that to be true of the decrement arriving
38+
// at zero. This could in theory be modeled by conditional fences, but those
39+
// have their own problems and we don't need to model the more precise
40+
// semantics for efficiency.
41+
auto previous = count_.fetch_sub(1, std::memory_order_acq_rel);
42+
43+
CARBON_CHECK(previous > 0);
44+
if (previous == 1) {
45+
// Ensure that our closure is fully destroyed here, releasing any
46+
// resources, locks, or other synchronization primitives.
47+
auto on_zero = std::exchange(on_zero_, [] {});
48+
std::move(on_zero)();
49+
return true;
50+
}
51+
return false;
52+
}
53+
54+
auto Latch::Init(llvm::unique_function<auto()->void> on_zero) -> Handle {
55+
CARBON_CHECK(count_ == 0);
56+
on_zero_ = std::move(on_zero);
57+
return Handle(this);
58+
}
59+
60+
} // namespace Carbon

common/latch.h

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Part of the Carbon Language project, under the Apache License v2.0 with LLVM
2+
// Exceptions. See /LICENSE for license information.
3+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
4+
5+
#ifndef CARBON_COMMON_LATCH_H_
6+
#define CARBON_COMMON_LATCH_H_
7+
8+
#include <atomic>
9+
10+
#include "llvm/ADT/FunctionExtras.h"
11+
12+
namespace Carbon {
13+
14+
// A synchronization primitive similar to `std::latch` to coordinate starting
15+
// some action once all of a set of other actions complete.
16+
//
17+
// Users initialize the latch (with `Init`), and receive a handle RAII object.
18+
// This handle can be copied, and the latch is satisfied when the last copy of
19+
// the handle returned by `Init` is destroyed.
20+
//
21+
// The latch synchronizes between every destruction of a handle and the
22+
// destruction of the last handle, allowing code that runs after the latch is
23+
// satisfied to access everything written by any thread that destroyed a handle.
24+
// For more details of the synchronization mechanics, see the comments on `Inc`
25+
// and `Dec` that implement this logic.
26+
//
27+
// This type also supports holding a closure to run when satisfied to simplify
28+
// patterns where that body of code is easier to express at the start of work
29+
// being synchronized instead of as each work item completes.
30+
//
31+
// The initialization API is separate from the constructor both for convenience
32+
// and to enable it to provide the initial handle. This makes it easy to build
33+
// constructively correct code where each work unit holds a handle until
34+
// finished, including the initializer of the latch, often using by-value
35+
// captures in a lambda that does the work.
36+
class Latch {
37+
public:
38+
class Handle;
39+
40+
Latch() = default;
41+
Latch(const Latch&) = delete;
42+
Latch(Latch&&) = delete;
43+
44+
// Initialize a latch and get the initial handle to it.
45+
//
46+
// When the last copy of the returned handle is destroyed, the latch will be
47+
// satisfied.
48+
//
49+
// A closure may be provided which will be called when that last handle is
50+
// destroyed. Note that the closure will run on whatever thread executes the
51+
// last handle destruction. Typically, the closure here should _schedule_ the
52+
// next step of work on some thread pool rather than performing it directly.
53+
//
54+
// Once this method is called, it cannot be called again until all handles are
55+
// destroyed and the latch is satisfied. It can then be called again to get a
56+
// fresh handle (and provide a new closure if desired).
57+
auto Init(llvm::unique_function<auto()->void> on_zero = [] {}) -> Handle;
58+
59+
private:
60+
// Increments the latch's counter.
61+
//
62+
// This is thread-safe, and may be called concurrently on multiple threads,
63+
// and may be called concurrently with `Dec`. However, the caller _must_ call
64+
// `Inc` and then `Dec`, and provide some happens-before relationship between
65+
// the `Inc` and `Dec`. Typically, this is done with either same-thread
66+
// happens-before, or because some other synchronization event such as
67+
// starting a thread or popping a task from a thread pool provides the
68+
// inter-thread happens-before relationship.
69+
auto Inc() -> void;
70+
71+
// Decrements the latch's counter, and returns true when it reaches zero.
72+
//
73+
// This is thread-safe, and may be called concurrently with other calls to
74+
// `Dec` or `Inc`.
75+
//
76+
// It also ensures that all threads which call `Dec` and receive `false`
77+
// synchronize-with the thread that calls `Dec` and receives `true`. As a
78+
// consequence everything that happens-before the call to `Dec` has an
79+
// inter-thread happens-before for any code when `Dec` returns `true`.
80+
//
81+
// Note that there is no guarantee of inter-thread happens-before to
82+
// operations after a `Dec` call that returns `false`.
83+
auto Dec() -> bool;
84+
85+
std::atomic<int> count_;
86+
llvm::unique_function<auto()->void> on_zero_;
87+
};
88+
89+
// A copyable RAII handle around a `Latch`.
90+
//
91+
// When the last copy of a handle returned by `Latch::Init` is destroyed, the
92+
// latch is considered satisfied. Copying is supported by incrementing the
93+
// count of the latch. That increment can always be performed because it starts
94+
// from a live handle and so the count cannot have reached zero.
95+
//
96+
// For more details, see the `Latch` class.
97+
class Latch::Handle {
98+
public:
99+
Handle(const Handle& arg) : latch_(arg.latch_) {
100+
if (latch_) {
101+
arg.latch_->Inc();
102+
}
103+
}
104+
Handle(Handle&& arg) noexcept : latch_(std::exchange(arg.latch_, nullptr)) {}
105+
106+
~Handle() {
107+
if (latch_) {
108+
latch_->Dec();
109+
}
110+
}
111+
112+
// Drops a handle explicitly, rather than waiting for it to fall out of scope.
113+
//
114+
// This also allows observing whether the underlying latch is satisfied.
115+
// Calls to this function synchronize with all other drops or destructions of
116+
// latch handles when it returns true, and only the last will return true.
117+
auto Drop() && -> bool {
118+
bool last = latch_->Dec();
119+
latch_ = nullptr;
120+
return last;
121+
}
122+
123+
private:
124+
friend Latch;
125+
126+
// Private constructor used by `Latch::Init` to create the initial handle for
127+
// a latch.
128+
explicit Handle(Latch* latch) : latch_(latch) { latch_->Inc(); }
129+
130+
Latch* latch_ = nullptr;
131+
};
132+
133+
} // namespace Carbon
134+
135+
#endif // CARBON_COMMON_LATCH_H_

common/latch_test.cpp

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Part of the Carbon Language project, under the Apache License v2.0 with LLVM
2+
// Exceptions. See /LICENSE for license information.
3+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
4+
5+
#include "common/latch.h"
6+
7+
#include <gtest/gtest.h>
8+
9+
#include <chrono>
10+
#include <thread>
11+
#include <vector>
12+
13+
namespace Carbon {
14+
namespace {
15+
16+
// Basic test for the Latch.
17+
TEST(LatchTest, Basic) {
18+
Latch latch;
19+
Latch::Handle handle = latch.Init();
20+
// Dropping a copy doesn't satisfy the latch.
21+
Latch::Handle handle_copy = handle;
22+
EXPECT_FALSE(std::move(handle_copy).Drop());
23+
// Can create more copies even after we start dropping.
24+
Latch::Handle handle_copy2 = handle;
25+
EXPECT_FALSE(std::move(handle_copy2).Drop());
26+
// Now drop the last handle.
27+
EXPECT_TRUE(std::move(handle).Drop());
28+
}
29+
30+
// Tests that the on-zero callback is called.
31+
TEST(LatchTest, OnZeroCallback) {
32+
Latch latch;
33+
bool called = false;
34+
Latch::Handle handle = latch.Init([&] { called = true; });
35+
Latch::Handle handle2 = handle;
36+
Latch::Handle handle3 = handle;
37+
38+
EXPECT_FALSE(called);
39+
EXPECT_FALSE(std::move(handle).Drop());
40+
EXPECT_FALSE(called);
41+
EXPECT_FALSE(std::move(handle2).Drop());
42+
EXPECT_FALSE(called);
43+
EXPECT_TRUE(std::move(handle3).Drop());
44+
EXPECT_TRUE(called);
45+
}
46+
47+
// Tests moving a handle.
48+
TEST(LatchTest, MoveHandle) {
49+
Latch latch;
50+
bool called = false;
51+
Latch::Handle handle = latch.Init([&] { called = true; });
52+
Latch::Handle handle2 = std::move(handle);
53+
54+
// Check that dropping the new handle satisfies the latch.
55+
EXPECT_FALSE(called);
56+
EXPECT_TRUE(std::move(handle2).Drop());
57+
EXPECT_TRUE(called);
58+
}
59+
60+
// Test that creating and destroying a handle without dropping works.
61+
TEST(LatchTest, Destructor) {
62+
Latch latch;
63+
bool called = false;
64+
Latch::Handle handle = latch.Init([&] { called = true; });
65+
{
66+
// NOLINTNEXTLINE(performance-unnecessary-copy-initialization)
67+
Latch::Handle handle2 = handle;
68+
EXPECT_FALSE(called);
69+
}
70+
EXPECT_FALSE(called);
71+
EXPECT_TRUE(std::move(handle).Drop());
72+
EXPECT_TRUE(called);
73+
}
74+
75+
// Tests calling `Init` more than once.
76+
TEST(LatchTest, Reuse) {
77+
Latch latch;
78+
bool called = false;
79+
Latch::Handle handle = latch.Init([&] { called = true; });
80+
Latch::Handle handle2 = handle;
81+
82+
EXPECT_FALSE(called);
83+
EXPECT_FALSE(std::move(handle).Drop());
84+
EXPECT_FALSE(called);
85+
EXPECT_TRUE(std::move(handle2).Drop());
86+
EXPECT_TRUE(called);
87+
88+
// Now initialize the latch again with a new closure.
89+
bool called2 = false;
90+
Latch::Handle handle3 = latch.Init([&] { called2 = true; });
91+
Latch::Handle handle4 = handle3;
92+
93+
EXPECT_FALSE(called2);
94+
EXPECT_FALSE(std::move(handle3).Drop());
95+
EXPECT_FALSE(called2);
96+
EXPECT_TRUE(std::move(handle4).Drop());
97+
EXPECT_TRUE(called2);
98+
}
99+
100+
// Tests the latch with multiple threads.
101+
TEST(LatchTest, MultiThreaded) {
102+
Latch latch;
103+
std::atomic<int> counter = 0;
104+
bool called = false;
105+
constexpr int NumThreads = 5;
106+
107+
// The `on_zero` callback will be executed by the last thread to drop its
108+
// handle.
109+
auto handle = latch.Init([&] {
110+
// Check that all threads have done their work.
111+
EXPECT_EQ(counter.load(), NumThreads);
112+
called = true;
113+
});
114+
115+
std::vector<std::thread> threads;
116+
threads.reserve(NumThreads);
117+
for (int i = 0; i < NumThreads; ++i) {
118+
threads.emplace_back([&, handle_copy = handle] {
119+
// Each thread has its own copy of the handle.
120+
// Simulate some work.
121+
std::this_thread::sleep_for(std::chrono::milliseconds(10));
122+
counter++;
123+
// The handle is dropped when the thread exits.
124+
});
125+
}
126+
127+
// Drop the main thread's handle.
128+
std::move(handle).Drop();
129+
130+
for (auto& thread : threads) {
131+
thread.join();
132+
}
133+
EXPECT_TRUE(called);
134+
}
135+
136+
} // namespace
137+
} // namespace Carbon

0 commit comments

Comments
 (0)