Skip to content

Commit cfe972e

Browse files
committed
Add synchronization primitives
Allows the use of `fine::Mutex` and `fine::RwLock` as drop-in replacements for `std::mutex` and `std::shared_mutex` respectively. Additional functionality is provided to enable usage of tooling like *lock checker* and *lcnt*.
1 parent 144cd7b commit cfe972e

6 files changed

Lines changed: 388 additions & 2 deletions

File tree

README.md

Lines changed: 71 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,75 @@ 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::RwLock`:
515+
516+
```c++
517+
#include <fine/mutex.hpp>
518+
#include <fine/rwlock.hpp>
519+
520+
fine::Mutex mutex;
521+
522+
mutex.lock();
523+
mutex.unlock();
524+
bool locked = mutex.try_lock();
525+
526+
{
527+
auto lock = std::unique_lock(mutex);
528+
}
529+
530+
fine::RwLock rwlock;
531+
532+
rwlock.lock();
533+
rwlock.unlock();
534+
bool locked = rwlock.try_lock();
535+
536+
rwlock.lock_shared();
537+
rwlock.unlock_shared();
538+
bool locked = rwlock.try_lock_shared();
539+
540+
{
541+
auto lock = std::shared_lock(rwlock);
542+
}
543+
544+
{
545+
auto lock = std::unique_lock(rwlock);
546+
}
547+
```
548+
549+
While `fine::Mutex` and `fine::RwLock` can be created using their default
550+
constructors, users might want to explore their constructors accepting debug
551+
information:
552+
```c++
553+
fine::Mutex mutex("app_name", "type_name");
554+
fine::Mutex mutex("app_name", "type_name", "instance_name");
555+
fine::RwLock rwlock("app_name", "type_name");
556+
fine::RwLock rwlock("app_name", "type_name", "instance_name");
557+
```
558+
559+
Conventionnally, `"app_name"` is a string representation of the application
560+
associated with the loaded NIFs, `"type_name"` is the type wrapped by the
561+
synchronization primitive, and `"instance_name"` identifies and instance of the
562+
type indivually. The `"instance_name"` is normally omitted if
563+
the synchronization primitive is global.
564+
565+
Say we were making a wrapper for *libmy_lib* in a app named *my_lib*, we
566+
would create a read/write lock for an object of type *my_object* like so:
567+
```c++
568+
struct my_object;
569+
const char* my_object__name(struct my_object*);
570+
571+
fine::RwLock my_object_rwlock("my_lib", "my_object", my_object__name(my_object));
572+
```
573+
503574
<!-- Docs -->
504575
505576
## Prior work

include/fine/mutex.hpp

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#ifndef FINE_MUTEX_HPP
2+
#define FINE_MUTEX_HPP
3+
#pragma once
4+
5+
#include <cstddef>
6+
#include <memory>
7+
#include <mutex> // IWYU pragma: keep
8+
#include <optional>
9+
#include <sstream>
10+
#include <stdexcept>
11+
#include <string>
12+
#include <string_view>
13+
14+
#include <erl_nif.h>
15+
16+
namespace fine {
17+
// Creates a mutually exclusive lock backed by Erlang.
18+
//
19+
// This mutex type implements the Lockable requirement, ensuring it can be used
20+
// with `std::unique_lock` and family.
21+
class Mutex final {
22+
public:
23+
// Creates an unnamed Mutex.
24+
inline Mutex() noexcept : Mutex(nullptr) {}
25+
26+
// Creates an unnamed Mutex.
27+
inline explicit Mutex(std::nullptr_t) : m_handle(enif_mutex_create(nullptr)) {
28+
if (!m_handle) {
29+
throw std::runtime_error("failed to create mutex");
30+
}
31+
}
32+
33+
// Creates a Mutex from a ErlNifMutex handle.
34+
inline explicit Mutex(ErlNifMutex *handle) noexcept : m_handle(handle) {}
35+
36+
// Creates a Mutex with the given debug information.
37+
inline Mutex(std::string_view app, std::string_view type,
38+
std::optional<std::string_view> instance = std::nullopt) {
39+
std::stringstream stream;
40+
stream << app << "." << type;
41+
42+
if (instance) {
43+
stream << "[" << *instance << "]";
44+
}
45+
46+
std::string str = std::move(stream).str();
47+
48+
// We make use of `const_cast` to create the mutex, but this exceptional
49+
// situation is acceptable, as `enif_mutex_create` doesn't modify the name:
50+
// https://github.com/erlang/otp/blob/a87183f1eb847119b6ecc83054bf13c26b8ccfaa/erts/emulator/beam/erl_drv_thread.c#L166-L169
51+
auto *handle = enif_mutex_create(const_cast<char *>(str.c_str()));
52+
if (handle == nullptr) {
53+
throw std::runtime_error("failed to create mutex");
54+
}
55+
m_handle.reset(handle);
56+
}
57+
58+
// Converts this Mutex to a ErlNifMutex handle.
59+
//
60+
// Ownership still belongs to this instance.
61+
inline operator ErlNifMutex *() const & noexcept { return m_handle.get(); }
62+
63+
// Releases ownership of the ErlNifMutex handle to the caller.
64+
//
65+
// This operation is only possible by:
66+
// ```
67+
// static_cast<ErlNifMutex*>(std::move(mutex))
68+
// ```
69+
inline explicit operator ErlNifMutex *() && noexcept {
70+
return m_handle.release();
71+
}
72+
73+
// Locks the Mutex. The calling thread is blocked until the Mutex has been
74+
// locked. A thread that has currently locked the Mutex cannot lock the same
75+
// Mutex again.
76+
//
77+
// This function is thread-safe.
78+
inline void lock() noexcept { enif_mutex_lock(m_handle.get()); }
79+
80+
// Unlocks a Mutex. The Mutex currently must be locked by the calling thread.
81+
//
82+
// This function is thread-safe.
83+
inline void unlock() noexcept { enif_mutex_unlock(m_handle.get()); }
84+
85+
// Tries to lock a Mutex. A thread that has currently locked the Mutex cannot
86+
// try to lock the same Mutex again.
87+
//
88+
// This function is thread-safe.
89+
inline bool try_lock() noexcept {
90+
return enif_mutex_trylock(m_handle.get()) == 0;
91+
}
92+
93+
private:
94+
struct Deleter {
95+
inline void operator()(ErlNifMutex *handle) const noexcept {
96+
enif_mutex_destroy(handle);
97+
}
98+
};
99+
std::unique_ptr<ErlNifMutex, Deleter> m_handle;
100+
};
101+
} // namespace fine
102+
103+
#endif

include/fine/rwlock.hpp

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#ifndef FINE_RWLOCK_HPP
2+
#define FINE_RWLOCK_HPP
3+
4+
#include <cstddef>
5+
#include <memory>
6+
#include <optional>
7+
#include <shared_mutex> // IWYU pragma: keep
8+
#include <sstream>
9+
#include <stdexcept>
10+
#include <string>
11+
#include <string_view>
12+
13+
#include <erl_nif.h>
14+
15+
namespace fine {
16+
// Creates a read-write lock backed by Erlang.
17+
//
18+
// This lock type implements the Lockable and SharedLockable requirements,
19+
// ensuring it can be used with `std::unique_lock`, `std::shared_lock`, etc.
20+
class RwLock final {
21+
public:
22+
// Creates an unnamed RwLock.
23+
inline RwLock() noexcept : RwLock(nullptr) {}
24+
25+
// Creates an unnamed RwLock.
26+
inline explicit RwLock(std::nullptr_t)
27+
: m_handle(enif_rwlock_create(nullptr)) {
28+
if (!m_handle) {
29+
throw std::runtime_error("failed to create rwlock");
30+
}
31+
}
32+
33+
// Creates a RwLock from a ErlNifRWLock handle.
34+
inline explicit RwLock(ErlNifRWLock *handle) noexcept : m_handle(handle) {}
35+
36+
// Creates a RwLock with the given name.
37+
inline RwLock(std::string_view app, std::string_view type,
38+
std::optional<std::string_view> instance = std::nullopt) {
39+
std::stringstream stream;
40+
stream << app << "." << type;
41+
42+
if (instance) {
43+
stream << "[" << *instance << "]";
44+
}
45+
46+
std::string str = std::move(stream).str();
47+
48+
// We make use of `const_cast` to create the rwlock, but this exceptional
49+
// situation is acceptable, as `enif_rwlock_create` doesn't modify the name:
50+
// https://github.com/erlang/otp/blob/a87183f1eb847119b6ecc83054bf13c26b8ccfaa/ert/emulator/beam/erl_drv_thread.c#L337-L340
51+
auto *handle = enif_rwlock_create(const_cast<char *>(str.c_str()));
52+
if (handle == nullptr) {
53+
throw std::runtime_error("failed to create rwlock");
54+
}
55+
m_handle.reset(handle);
56+
}
57+
58+
// Converts this RwLock to a ErlNifRwLock handle.
59+
//
60+
// Ownership still belongs to this instance.
61+
inline operator ErlNifRWLock *() const & noexcept { return m_handle.get(); }
62+
63+
// Releases ownership of the ErlNifRWLock handle to the caller.
64+
//
65+
// This operation is only possible by:
66+
// ```
67+
// static_cast<ErlNifRWLock*>(std::move(rwlock))
68+
// ```
69+
inline explicit operator ErlNifRWLock *() && noexcept {
70+
return m_handle.release();
71+
}
72+
73+
// Read locks an RwLock. The calling thread is blocked until the RwLock has
74+
// been read locked. A thread that currently has read or read/write locked the
75+
// RwLock cannot lock the same RwLock again.
76+
//
77+
// This function is thread-safe.
78+
inline void lock_shared() noexcept { enif_rwlock_rlock(m_handle.get()); }
79+
80+
// Read unlocks an RwLock. The RwLock currently must be read locked by the
81+
// calling thread.
82+
//
83+
// This function is thread-safe.
84+
inline void unlock_shared() noexcept { enif_rwlock_runlock(m_handle.get()); }
85+
86+
// Read/write locks an RwLock. The calling thread is blocked until the RwLock
87+
// has been read/write locked. A thread that currently has read or read/write
88+
// locked the RwLock cannot lock the same RwLock again.
89+
//
90+
// This function is thread-safe.
91+
inline void lock() noexcept { enif_rwlock_rwlock(m_handle.get()); }
92+
93+
// Read/write unlocks an RwLock. The RwLock currently must be read/write
94+
// locked by the calling thread.
95+
//
96+
// This function is thread-safe.
97+
inline void unlock() noexcept { enif_rwlock_rwunlock(m_handle.get()); }
98+
99+
// Tries to read lock an RwLock.
100+
//
101+
// This function is thread-safe.
102+
inline bool try_lock_shared() noexcept {
103+
return enif_rwlock_tryrlock(m_handle.get()) == 0;
104+
}
105+
106+
// Tries to read/write lock an RwLock. A thread that currently has read or
107+
// read/write locked the RwLock cannot try to lock the same RwLock again.
108+
//
109+
// This function is thread-safe.
110+
inline bool try_lock() noexcept {
111+
return enif_rwlock_tryrwlock(m_handle.get()) == 0;
112+
}
113+
114+
private:
115+
struct Deleter {
116+
inline void operator()(ErlNifRWLock *handle) const noexcept {
117+
enif_rwlock_destroy(handle);
118+
}
119+
};
120+
std::unique_ptr<ErlNifRWLock, Deleter> m_handle;
121+
};
122+
} // namespace fine
123+
124+
#endif

test/c_src/finest.cpp

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
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/mutex.hpp>
10+
#include <fine/rwlock.hpp>
11+
912
namespace finest {
1013

1114
namespace atoms {
@@ -234,6 +237,65 @@ int64_t raise_erlang_error(ErlNifEnv *env) {
234237
}
235238
FINE_NIF(raise_erlang_error, 0);
236239

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

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

0 commit comments

Comments
 (0)