Skip to content

Commit 413a301

Browse files
Add synchronization primitives (#7)
Co-authored-by: Jonatan Kłosko <[email protected]>
1 parent 144cd7b commit 413a301

File tree

5 files changed

+346
-2
lines changed

5 files changed

+346
-2
lines changed

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ NIFs in C++.
3030

3131
- Creating all static atoms at load time.
3232

33+
- STL compatible Erlang-backend mutex and rwlock.
34+
3335
## Motivation
3436

3537
Some projects make extensive use of NIFs, where using the C API results
@@ -500,6 +502,62 @@ std::variant<fine::Ok<int64_t>, fine::Error<std::string>> find_meaning(ErlNifEnv
500502
Note that if you use a particular union frequently, it may be convenient
501503
to define a type alias with `using`/`typedef` to keep signatures brief.
502504
505+
## Synchronization
506+
507+
Erlang is a multi-process environment where each process is guaranteed to be
508+
isolated from other processes. When dealing with NIFs, the same C++ function
509+
can be called from multiple Erlang processes simultaneously, leading to race
510+
conditions. While C++ provides synchronization mechanisms, these are unknown to
511+
Erlang and cannot take advantage of tools like *lock checker* or *lcnt*.
512+
513+
Fine provides analogues to `std::mutex` and `std::shared_mutex`, respectively
514+
called `fine::Mutex` and `fine::SharedMutex`. Those are compatible with the
515+
standard mutex wrappers, such as `std::unique_lock` and `std::shared_lock`.
516+
For example:
517+
518+
```c++
519+
#include <fine/sync.hpp>
520+
521+
fine::Mutex mutex;
522+
523+
{
524+
auto lock = std::unique_lock(mutex);
525+
...
526+
}
527+
528+
fine::SharedMutex mutex;
529+
530+
{
531+
auto lock = std::shared_lock(mutex);
532+
...
533+
}
534+
```
535+
536+
While `fine::Mutex` and `fine::SharedMutex` can be created using their default
537+
constructors, users might want to explore their constructors accepting debug
538+
information:
539+
```c++
540+
fine::Mutex mutex("app_name", "type_name");
541+
fine::Mutex mutex("app_name", "type_name", "instance_name");
542+
fine::SharedMutex rwlock("app_name", "type_name");
543+
fine::SharedMutex rwlock("app_name", "type_name", "instance_name");
544+
```
545+
546+
Conventionnally, `"app_name"` is a string representation of the application
547+
associated with the loaded NIFs, `"type_name"` is the type wrapped by the
548+
synchronization primitive, and `"instance_name"` identifies and instance of the
549+
type indivually. The `"instance_name"` is normally omitted if
550+
the synchronization primitive is global.
551+
552+
Say we were making a wrapper for *libmy_lib* in a app named *my_lib*, we
553+
would create a read/write lock for an object of type *my_object* like so:
554+
```c++
555+
struct my_object;
556+
const char* my_object__name(struct my_object*);
557+
558+
fine::SharedMutex my_object_rwlock("my_lib", "my_object", my_object__name(my_object));
559+
```
560+
503561
<!-- Docs -->
504562
505563
## Prior work

include/fine/sync.hpp

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#ifndef FINE_SYNC_HPP
2+
#define FINE_SYNC_HPP
3+
#pragma once
4+
5+
#include <cstddef>
6+
#include <memory>
7+
#include <mutex> // IWYU pragma: keep
8+
#include <optional>
9+
#include <shared_mutex> // IWYU pragma: keep
10+
#include <sstream>
11+
#include <stdexcept>
12+
#include <string>
13+
#include <string_view>
14+
15+
#include <erl_nif.h>
16+
17+
namespace fine {
18+
// Creates a mutually exclusive lock backed by Erlang.
19+
//
20+
// This mutex type implements the Lockable requirement, ensuring it can be used
21+
// with `std::unique_lock` and family.
22+
class Mutex final {
23+
public:
24+
// Creates an unnamed Mutex.
25+
Mutex() : m_handle(enif_mutex_create(nullptr)) {
26+
if (!m_handle) {
27+
throw std::runtime_error("failed to create mutex");
28+
}
29+
}
30+
31+
// Creates a Mutex from a ErlNifMutex handle.
32+
explicit Mutex(ErlNifMutex *handle) noexcept : m_handle(handle) {}
33+
34+
// Creates a Mutex with the given debug information.
35+
Mutex(std::string_view app, std::string_view type,
36+
std::optional<std::string_view> instance = std::nullopt) {
37+
std::stringstream stream;
38+
stream << app << "." << type;
39+
40+
if (instance) {
41+
stream << "[" << *instance << "]";
42+
}
43+
44+
std::string str = std::move(stream).str();
45+
46+
// We make use of `const_cast` to create the mutex, but this exceptional
47+
// situation is acceptable, as `enif_mutex_create` doesn't modify the name:
48+
// https://github.com/erlang/otp/blob/a87183f1eb847119b6ecc83054bf13c26b8ccfaa/erts/emulator/beam/erl_drv_thread.c#L166-L169
49+
auto *handle = enif_mutex_create(const_cast<char *>(str.c_str()));
50+
if (handle == nullptr) {
51+
throw std::runtime_error("failed to create mutex");
52+
}
53+
m_handle.reset(handle);
54+
}
55+
56+
// Converts this Mutex to a ErlNifMutex handle.
57+
//
58+
// Ownership still belongs to this instance.
59+
operator ErlNifMutex *() const & noexcept { return m_handle.get(); }
60+
61+
// Releases ownership of the ErlNifMutex handle to the caller.
62+
//
63+
// This operation is only possible by:
64+
// ```
65+
// static_cast<ErlNifMutex*>(std::move(mutex))
66+
// ```
67+
explicit operator ErlNifMutex *() && noexcept { return m_handle.release(); }
68+
69+
// Locks the Mutex. The calling thread is blocked until the Mutex has been
70+
// locked. A thread that has currently locked the Mutex cannot lock the same
71+
// Mutex again.
72+
//
73+
// This function is thread-safe.
74+
void lock() noexcept { enif_mutex_lock(m_handle.get()); }
75+
76+
// Unlocks a Mutex. The Mutex currently must be locked by the calling thread.
77+
//
78+
// This function is thread-safe.
79+
void unlock() noexcept { enif_mutex_unlock(m_handle.get()); }
80+
81+
// Tries to lock a Mutex. A thread that has currently locked the Mutex cannot
82+
// try to lock the same Mutex again.
83+
//
84+
// This function is thread-safe.
85+
bool try_lock() noexcept { return enif_mutex_trylock(m_handle.get()) == 0; }
86+
87+
private:
88+
struct Deleter {
89+
void operator()(ErlNifMutex *handle) const noexcept {
90+
enif_mutex_destroy(handle);
91+
}
92+
};
93+
std::unique_ptr<ErlNifMutex, Deleter> m_handle;
94+
};
95+
96+
// Creates a read-write lock backed by Erlang.
97+
//
98+
// This lock type implements the Lockable and SharedLockable requirements,
99+
// ensuring it can be used with `std::unique_lock`, `std::shared_lock`, etc.
100+
class SharedMutex final {
101+
public:
102+
// Creates an unnamed SharedMutex.
103+
SharedMutex() : m_handle(enif_rwlock_create(nullptr)) {
104+
if (!m_handle) {
105+
throw std::runtime_error("failed to create rwlock");
106+
}
107+
}
108+
109+
// Creates a SharedMutex from a ErlNifRWLock handle.
110+
explicit SharedMutex(ErlNifRWLock *handle) noexcept : m_handle(handle) {}
111+
112+
// Creates a SharedMutex with the given name.
113+
SharedMutex(std::string_view app, std::string_view type,
114+
std::optional<std::string_view> instance = std::nullopt) {
115+
std::stringstream stream;
116+
stream << app << "." << type;
117+
118+
if (instance) {
119+
stream << "[" << *instance << "]";
120+
}
121+
122+
std::string str = std::move(stream).str();
123+
124+
// We make use of `const_cast` to create the rwlock, but this exceptional
125+
// situation is acceptable, as `enif_rwlock_create` doesn't modify the name:
126+
// https://github.com/erlang/otp/blob/a87183f1eb847119b6ecc83054bf13c26b8ccfaa/ert/emulator/beam/erl_drv_thread.c#L337-L340
127+
auto *handle = enif_rwlock_create(const_cast<char *>(str.c_str()));
128+
if (handle == nullptr) {
129+
throw std::runtime_error("failed to create rwlock");
130+
}
131+
m_handle.reset(handle);
132+
}
133+
134+
// Converts this SharedMutex to a ErlNifSharedMutex handle.
135+
//
136+
// Ownership still belongs to this instance.
137+
operator ErlNifRWLock *() const & noexcept { return m_handle.get(); }
138+
139+
// Releases ownership of the ErlNifRWLock handle to the caller.
140+
//
141+
// This operation is only possible by:
142+
// ```
143+
// static_cast<ErlNifRWLock*>(std::move(rwlock))
144+
// ```
145+
explicit operator ErlNifRWLock *() && noexcept { return m_handle.release(); }
146+
147+
// Read locks a SharedMutex. The calling thread is blocked until the
148+
// SharedMutex has been read locked. A thread that currently has read or
149+
// read/write locked the SharedMutex cannot lock the same SharedMutex again.
150+
//
151+
// This function is thread-safe.
152+
void lock_shared() noexcept { enif_rwlock_rlock(m_handle.get()); }
153+
154+
// Read unlocks a SharedMutex. The SharedMutex currently must be read locked
155+
// by the calling thread.
156+
//
157+
// This function is thread-safe.
158+
void unlock_shared() noexcept { enif_rwlock_runlock(m_handle.get()); }
159+
160+
// Read/write locks a SharedMutex. The calling thread is blocked until the
161+
// SharedMutex has been read/write locked. A thread that currently has read or
162+
// read/write locked the SharedMutex cannot lock the same SharedMutex again.
163+
//
164+
// This function is thread-safe.
165+
void lock() noexcept { enif_rwlock_rwlock(m_handle.get()); }
166+
167+
// Read/write unlocks a SharedMutex. The SharedMutex currently must be
168+
// read/write locked by the calling thread.
169+
//
170+
// This function is thread-safe.
171+
void unlock() noexcept { enif_rwlock_rwunlock(m_handle.get()); }
172+
173+
// Tries to read lock a SharedMutex.
174+
//
175+
// This function is thread-safe.
176+
bool try_lock_shared() noexcept {
177+
return enif_rwlock_tryrlock(m_handle.get()) == 0;
178+
}
179+
180+
// Tries to read/write lock a SharedMutex. A thread that currently has read
181+
// or read/write locked the SharedMutex cannot try to lock the same
182+
// SharedMutex again.
183+
//
184+
// This function is thread-safe.
185+
bool try_lock() noexcept {
186+
return enif_rwlock_tryrwlock(m_handle.get()) == 0;
187+
}
188+
189+
private:
190+
struct Deleter {
191+
void operator()(ErlNifRWLock *handle) const noexcept {
192+
enif_rwlock_destroy(handle);
193+
}
194+
};
195+
std::unique_ptr<ErlNifRWLock, Deleter> m_handle;
196+
};
197+
} // namespace fine
198+
199+
#endif

test/c_src/finest.cpp

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
#include <cstring>
2-
#include <erl_nif.h>
32
#include <exception>
4-
#include <fine.hpp>
53
#include <optional>
64
#include <stdexcept>
75
#include <thread>
86

7+
#include <erl_nif.h>
8+
#include <fine.hpp>
9+
#include <fine/sync.hpp>
10+
911
namespace finest {
1012

1113
namespace atoms {
@@ -234,6 +236,65 @@ int64_t raise_erlang_error(ErlNifEnv *env) {
234236
}
235237
FINE_NIF(raise_erlang_error, 0);
236238

239+
std::nullopt_t mutex_unique_lock_test(ErlNifEnv *) {
240+
fine::Mutex mutex;
241+
mutex.lock();
242+
243+
std::thread thread([&mutex] { auto lock = std::unique_lock(mutex); });
244+
245+
mutex.unlock();
246+
thread.join();
247+
248+
return std::nullopt;
249+
}
250+
FINE_NIF(mutex_unique_lock_test, 0);
251+
252+
std::nullopt_t mutex_scoped_lock_test(ErlNifEnv *) {
253+
fine::Mutex mutex1;
254+
fine::Mutex mutex2("finest", "mutex_scoped_lock_test", "mutex2");
255+
256+
mutex1.lock();
257+
mutex2.lock();
258+
259+
std::thread thread(
260+
[&mutex1, &mutex2] { auto lock = std::scoped_lock(mutex1, mutex2); });
261+
262+
mutex2.unlock();
263+
mutex1.unlock();
264+
thread.join();
265+
266+
return std::nullopt;
267+
}
268+
FINE_NIF(mutex_scoped_lock_test, 0);
269+
270+
std::nullopt_t shared_mutex_unique_lock_test(ErlNifEnv *) {
271+
fine::SharedMutex mutex;
272+
273+
mutex.lock_shared();
274+
275+
std::thread thread([&mutex] { auto lock = std::unique_lock(mutex); });
276+
277+
mutex.unlock_shared();
278+
thread.join();
279+
280+
return std::nullopt;
281+
}
282+
FINE_NIF(shared_mutex_unique_lock_test, 0);
283+
284+
std::nullopt_t shared_mutex_shared_lock_test(ErlNifEnv *) {
285+
fine::SharedMutex mutex("finest", "shared_mutex_shared_lock_test", "mutex");
286+
287+
mutex.lock();
288+
289+
std::thread thread([&mutex] { auto lock = std::shared_lock(mutex); });
290+
291+
mutex.unlock();
292+
thread.join();
293+
294+
return std::nullopt;
295+
}
296+
FINE_NIF(shared_mutex_shared_lock_test, 0);
297+
237298
} // namespace finest
238299

239300
FINE_INIT("Elixir.Finest.NIF");

test/lib/finest/nif.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,11 @@ defmodule Finest.NIF do
5050
def raise_elixir_exception(), do: err!()
5151
def raise_erlang_error(), do: err!()
5252

53+
def mutex_unique_lock_test(), do: err!()
54+
def mutex_scoped_lock_test(), do: err!()
55+
56+
def shared_mutex_unique_lock_test(), do: err!()
57+
def shared_mutex_shared_lock_test(), do: err!()
58+
5359
defp err!(), do: :erlang.nif_error(:not_loaded)
5460
end

test/test/finest_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,4 +287,24 @@ defmodule FinestTest do
287287
end
288288
end
289289
end
290+
291+
describe "mutex" do
292+
test "unique_lock" do
293+
NIF.mutex_unique_lock_test()
294+
end
295+
296+
test "scoped_lock" do
297+
NIF.mutex_scoped_lock_test()
298+
end
299+
end
300+
301+
describe "shared_mutex" do
302+
test "unique_lock" do
303+
NIF.shared_mutex_unique_lock_test()
304+
end
305+
306+
test "shared_lock" do
307+
NIF.shared_mutex_shared_lock_test()
308+
end
309+
end
290310
end

0 commit comments

Comments
 (0)