diff --git a/cpp/include/mh/concurrency/upgrade_mutex.hpp b/cpp/include/mh/concurrency/upgrade_mutex.hpp new file mode 100644 index 0000000..2337cb4 --- /dev/null +++ b/cpp/include/mh/concurrency/upgrade_mutex.hpp @@ -0,0 +1,245 @@ +#pragma once + +#include +#include +#include +#include + +#ifndef MH_STUFF_API +#define MH_STUFF_API +#endif + +namespace mh +{ + /** + * An upgradable shared mutex modeled on Boost.Thread's UpgradeLockable concept. + * + * Three ownership levels: + * - shared: any number of threads at once. + * - upgrade: at most ONE thread, coexists with shared owners. Holding upgrade + * ownership reserves the exclusive right to later upgrade to exclusive + * ownership without releasing the lock (two shared owners that both tried to + * upgrade in-place would deadlock waiting for each other to release). + * - exclusive: one thread, excludes shared, upgrade, and exclusive. + * + * Invariants/semantics: + * - unlock_upgrade_and_lock() marks an upgrade transition as pending, then waits + * for existing shared owners to drain. From that instant, new lock_shared() + * callers block and try_lock_shared() fails until the upgrader has taken (and + * released) its exclusive hold; lock_upgrade()/try_lock_upgrade() likewise. + * - The transition is non-preemptible: upgrade ownership is retained until the + * conversion completes, so no lock() caller can take exclusive ownership + * between the drain and the conversion. + * - Only the upgrade transition has priority over new shared acquires. Plain + * lock() has no such priority and can starve under continuous shared load. + * - Downgrades (exclusive->upgrade, exclusive->shared, upgrade->shared) never + * block. + * - No recursion. Unlocking a mode the calling thread does not hold is + * undefined behavior. + * + * Meets the standard SharedLockable requirements, so std::shared_lock and + * std::unique_lock work unmodified. mh::upgrade_lock is the RAII guard for + * upgrade ownership, and mh::upgrade_to_unique_lock is a scoped upgrade. + */ + class upgrade_mutex + { + public: + upgrade_mutex() = default; + upgrade_mutex(const upgrade_mutex&) = delete; + upgrade_mutex& operator=(const upgrade_mutex&) = delete; + + // Exclusive ownership + MH_STUFF_API void lock(); + MH_STUFF_API bool try_lock(); + MH_STUFF_API void unlock(); + + // Shared ownership + MH_STUFF_API void lock_shared(); + MH_STUFF_API bool try_lock_shared(); + MH_STUFF_API void unlock_shared(); + + // Upgrade ownership + MH_STUFF_API void lock_upgrade(); + MH_STUFF_API bool try_lock_upgrade(); + MH_STUFF_API void unlock_upgrade(); + + // Upgrade ownership -> exclusive ownership. Blocks new shared/upgrade + // acquires immediately, waits for existing shared owners to drain. + MH_STUFF_API void unlock_upgrade_and_lock(); + + // Downgrades. Never block. + MH_STUFF_API void unlock_and_lock_upgrade(); + MH_STUFF_API void unlock_and_lock_shared(); + MH_STUFF_API void unlock_upgrade_and_lock_shared(); + + private: + std::mutex m_Mutex; + std::condition_variable m_CV; + size_t m_SharedCount = 0; + bool m_HasUpgrade = false; + bool m_HasExclusive = false; + bool m_UpgradePending = false; + }; + + /** + * RAII guard for upgrade ownership of an UpgradeLockable mutex. Mirrors the + * shape of std::shared_lock, but calls lock_upgrade()/try_lock_upgrade()/ + * unlock_upgrade(). + */ + template + class upgrade_lock + { + public: + using mutex_type = Mutex; + + upgrade_lock() noexcept = default; + explicit upgrade_lock(mutex_type& mutex) : m_Mutex(&mutex), m_Owns(true) { mutex.lock_upgrade(); } + upgrade_lock(mutex_type& mutex, std::defer_lock_t) noexcept : m_Mutex(&mutex), m_Owns(false) {} + upgrade_lock(mutex_type& mutex, std::try_to_lock_t) : m_Mutex(&mutex), m_Owns(mutex.try_lock_upgrade()) {} + upgrade_lock(mutex_type& mutex, std::adopt_lock_t) noexcept : m_Mutex(&mutex), m_Owns(true) {} + + ~upgrade_lock() + { + if (m_Owns) + m_Mutex->unlock_upgrade(); + } + + upgrade_lock(const upgrade_lock&) = delete; + upgrade_lock& operator=(const upgrade_lock&) = delete; + + upgrade_lock(upgrade_lock&& other) noexcept : m_Mutex(other.m_Mutex), m_Owns(other.m_Owns) + { + other.m_Mutex = nullptr; + other.m_Owns = false; + } + upgrade_lock& operator=(upgrade_lock&& other) noexcept + { + if (m_Owns) + m_Mutex->unlock_upgrade(); + + m_Mutex = other.m_Mutex; + m_Owns = other.m_Owns; + other.m_Mutex = nullptr; + other.m_Owns = false; + return *this; + } + + void lock() + { + check_lockable(); + m_Mutex->lock_upgrade(); + m_Owns = true; + } + bool try_lock() + { + check_lockable(); + m_Owns = m_Mutex->try_lock_upgrade(); + return m_Owns; + } + void unlock() + { + if (!m_Owns) + throw std::system_error(std::make_error_code(std::errc::operation_not_permitted)); + + m_Mutex->unlock_upgrade(); + m_Owns = false; + } + + void swap(upgrade_lock& other) noexcept + { + std::swap(m_Mutex, other.m_Mutex); + std::swap(m_Owns, other.m_Owns); + } + mutex_type* release() noexcept + { + mutex_type* result = m_Mutex; + m_Mutex = nullptr; + m_Owns = false; + return result; + } + + [[nodiscard]] mutex_type* mutex() const noexcept { return m_Mutex; } + [[nodiscard]] bool owns_lock() const noexcept { return m_Owns; } + explicit operator bool() const noexcept { return m_Owns; } + + private: + void check_lockable() const + { + if (!m_Mutex) + throw std::system_error(std::make_error_code(std::errc::operation_not_permitted)); + if (m_Owns) + throw std::system_error(std::make_error_code(std::errc::resource_deadlock_would_occur)); + } + + mutex_type* m_Mutex = nullptr; + bool m_Owns = false; + }; + + /** + * Scoped upgrade in the style of boost::upgrade_to_unique_lock: the + * constructor takes an owning upgrade_lock and converts it to exclusive + * ownership via unlock_upgrade_and_lock(); the destructor downgrades via + * unlock_and_lock_upgrade() and returns upgrade ownership to the referenced + * upgrade_lock. While this object owns the exclusive lock, the source + * upgrade_lock reports owns_lock() == false. + */ + template + class upgrade_to_unique_lock + { + public: + using mutex_type = Mutex; + + explicit upgrade_to_unique_lock(upgrade_lock& lock) : m_Source(&lock) + { + if (!m_Source->owns_lock()) + throw std::system_error(std::make_error_code(std::errc::operation_not_permitted)); + + m_Source->mutex()->unlock_upgrade_and_lock(); + m_Mutex = m_Source->release(); + } + + ~upgrade_to_unique_lock() + { + if (m_Mutex) + { + m_Mutex->unlock_and_lock_upgrade(); + *m_Source = upgrade_lock(*m_Mutex, std::adopt_lock); + } + } + + upgrade_to_unique_lock(const upgrade_to_unique_lock&) = delete; + upgrade_to_unique_lock& operator=(const upgrade_to_unique_lock&) = delete; + + upgrade_to_unique_lock(upgrade_to_unique_lock&& other) noexcept : + m_Source(other.m_Source), m_Mutex(other.m_Mutex) + { + other.m_Source = nullptr; + other.m_Mutex = nullptr; + } + upgrade_to_unique_lock& operator=(upgrade_to_unique_lock&& other) noexcept + { + if (m_Mutex) + { + m_Mutex->unlock_and_lock_upgrade(); + *m_Source = upgrade_lock(*m_Mutex, std::adopt_lock); + } + + m_Source = other.m_Source; + m_Mutex = other.m_Mutex; + other.m_Source = nullptr; + other.m_Mutex = nullptr; + return *this; + } + + [[nodiscard]] bool owns_lock() const noexcept { return m_Mutex != nullptr; } + explicit operator bool() const noexcept { return owns_lock(); } + + private: + upgrade_lock* m_Source = nullptr; + mutex_type* m_Mutex = nullptr; + }; +} + +#ifndef MH_COMPILE_LIBRARY +#include "upgrade_mutex.inl" +#endif diff --git a/cpp/include/mh/concurrency/upgrade_mutex.inl b/cpp/include/mh/concurrency/upgrade_mutex.inl new file mode 100644 index 0000000..d039ace --- /dev/null +++ b/cpp/include/mh/concurrency/upgrade_mutex.inl @@ -0,0 +1,145 @@ +#ifdef MH_COMPILE_LIBRARY +#include "upgrade_mutex.hpp" +#endif + +#ifndef MH_COMPILE_LIBRARY_INLINE +#define MH_COMPILE_LIBRARY_INLINE inline +#endif + +namespace mh +{ + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::lock() + { + std::unique_lock lock(m_Mutex); + m_CV.wait(lock, [&] + { + return !m_HasExclusive && !m_HasUpgrade && !m_UpgradePending && m_SharedCount == 0; + }); + m_HasExclusive = true; + } + + MH_COMPILE_LIBRARY_INLINE bool upgrade_mutex::try_lock() + { + std::unique_lock lock(m_Mutex); + if (m_HasExclusive || m_HasUpgrade || m_UpgradePending || m_SharedCount != 0) + return false; + + m_HasExclusive = true; + return true; + } + + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::unlock() + { + { + std::unique_lock lock(m_Mutex); + m_HasExclusive = false; + } + m_CV.notify_all(); + } + + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::lock_shared() + { + std::unique_lock lock(m_Mutex); + m_CV.wait(lock, [&] + { + return !m_HasExclusive && !m_UpgradePending; + }); + m_SharedCount++; + } + + MH_COMPILE_LIBRARY_INLINE bool upgrade_mutex::try_lock_shared() + { + std::unique_lock lock(m_Mutex); + if (m_HasExclusive || m_UpgradePending) + return false; + + m_SharedCount++; + return true; + } + + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::unlock_shared() + { + { + std::unique_lock lock(m_Mutex); + m_SharedCount--; + } + m_CV.notify_all(); + } + + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::lock_upgrade() + { + std::unique_lock lock(m_Mutex); + m_CV.wait(lock, [&] + { + return !m_HasExclusive && !m_HasUpgrade && !m_UpgradePending; + }); + m_HasUpgrade = true; + } + + MH_COMPILE_LIBRARY_INLINE bool upgrade_mutex::try_lock_upgrade() + { + std::unique_lock lock(m_Mutex); + if (m_HasExclusive || m_HasUpgrade || m_UpgradePending) + return false; + + m_HasUpgrade = true; + return true; + } + + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::unlock_upgrade() + { + { + std::unique_lock lock(m_Mutex); + m_HasUpgrade = false; + } + m_CV.notify_all(); + } + + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::unlock_upgrade_and_lock() + { + std::unique_lock lock(m_Mutex); + + // Gate new shared/upgrade acquires first, then wait for existing shared + // owners to drain. m_HasUpgrade stays true throughout, so no lock() caller + // can take exclusive ownership between the drain and the conversion. + m_UpgradePending = true; + m_CV.wait(lock, [&] + { + return m_SharedCount == 0; + }); + + m_UpgradePending = false; + m_HasUpgrade = false; + m_HasExclusive = true; + } + + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::unlock_and_lock_upgrade() + { + { + std::unique_lock lock(m_Mutex); + m_HasExclusive = false; + m_HasUpgrade = true; + } + m_CV.notify_all(); + } + + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::unlock_and_lock_shared() + { + { + std::unique_lock lock(m_Mutex); + m_HasExclusive = false; + m_SharedCount++; + } + m_CV.notify_all(); + } + + MH_COMPILE_LIBRARY_INLINE void upgrade_mutex::unlock_upgrade_and_lock_shared() + { + { + std::unique_lock lock(m_Mutex); + m_HasUpgrade = false; + m_SharedCount++; + } + m_CV.notify_all(); + } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1e33f9b..630e9a2 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -56,6 +56,7 @@ mh_test(algorithm_algorithm_test) mh_test(chrono_helpers_test) mh_test(concurrency_dispatcher_test) mh_test(concurrency_thread_pool_test) +mh_test(concurrency_upgrade_mutex_test) mh_test(containers_heap_test) mh_test(coroutine_generator_test) mh_test(coroutine_task_test) diff --git a/test/concurrency_upgrade_mutex_test.cpp b/test/concurrency_upgrade_mutex_test.cpp new file mode 100644 index 0000000..2eb4b5d --- /dev/null +++ b/test/concurrency_upgrade_mutex_test.cpp @@ -0,0 +1,805 @@ +#include "mh/concurrency/upgrade_mutex.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace +{ + // "must not yet have happened" settle window. Long enough that a broken + // implementation reliably exposes itself, short enough to keep the suite fast. + constexpr std::chrono::milliseconds SETTLE_TIME = 200ms; + + // "must eventually happen" deadline. Generous so the suite stays reliable + // under ThreadSanitizer's 5-15x slowdown. + constexpr std::chrono::seconds WAIT_DEADLINE = 10s; + + template + bool eventually(TFunc&& func, std::chrono::steady_clock::duration timeout = WAIT_DEADLINE) + { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) + { + if (func()) + return true; + + std::this_thread::sleep_for(1ms); + } + + return false; + } + + // std::jthread is not available on all the libc++ versions CI builds with, + // so roll a minimal join-on-destruction thread. Joining in the destructor + // keeps a failed REQUIRE from tripping std::terminate via ~thread(). + class auto_join_thread + { + public: + template + explicit auto_join_thread(TFunc&& func) : m_Thread(std::forward(func)) {} + ~auto_join_thread() { join(); } + + auto_join_thread(const auto_join_thread&) = delete; + auto_join_thread& operator=(const auto_join_thread&) = delete; + + void join() + { + if (m_Thread.joinable()) + m_Thread.join(); + } + + private: + std::thread m_Thread; + }; + + // Runs a cleanup function on scope exit (including REQUIRE failure unwinds), + // at most once even if also invoked explicitly. + template + class scope_guard + { + public: + explicit scope_guard(TFunc func) : m_Func(std::move(func)) {} + ~scope_guard() { invoke(); } + + scope_guard(const scope_guard&) = delete; + scope_guard& operator=(const scope_guard&) = delete; + + void invoke() + { + if (!m_Invoked) + { + m_Invoked = true; + m_Func(); + } + } + + private: + TFunc m_Func; + bool m_Invoked = false; + }; + + // Order-of-events log. Threads append; the main thread asserts on ordering + // after joining (Catch2 assertions are not thread safe). + class event_log + { + public: + void add(std::string_view event) + { + std::lock_guard lock(m_Mutex); + m_Events.emplace_back(event); + } + + ptrdiff_t index_of(std::string_view event) const + { + std::lock_guard lock(m_Mutex); + for (size_t i = 0; i < m_Events.size(); i++) + { + if (m_Events[i] == event) + return static_cast(i); + } + + return -1; + } + + std::string str() const + { + std::lock_guard lock(m_Mutex); + std::string result; + for (const std::string& event : m_Events) + { + if (!result.empty()) + result += ", "; + + result += event; + } + + return result; + } + + private: + mutable std::mutex m_Mutex; + std::vector m_Events; + }; + + // try_lock_shared probe that never retains ownership. Fails only while + // exclusive is held or an upgrade transition is pending, so it doubles as a + // "transition pending" detector when exclusive is known not to be held. + bool probe_try_lock_shared(mh::upgrade_mutex& mutex) + { + if (mutex.try_lock_shared()) + { + mutex.unlock_shared(); + return true; + } + + return false; + } + + // Verifies every ownership mode is fully released: try_lock only succeeds + // with no shared owners, no upgrade owner, no exclusive owner, and no + // pending transition. + bool fully_released(mh::upgrade_mutex& mutex) + { + if (mutex.try_lock()) + { + mutex.unlock(); + return true; + } + + return false; + } +} + +TEST_CASE("upgrade_mutex - single-thread state machine") +{ + mh::upgrade_mutex m; + + // exclusive: lock/unlock, then re-acquire via try_lock + m.lock(); + m.unlock(); + REQUIRE(fully_released(m)); + + // shared: multiple times from one thread (distinct acquisitions, no recursion + // involved: each lock_shared is a separate shared owner slot) + m.lock_shared(); + m.lock_shared(); + REQUIRE(m.try_lock_shared()); + m.unlock_shared(); + m.unlock_shared(); + m.unlock_shared(); + REQUIRE(fully_released(m)); + + // upgrade: acquire/release, then re-acquire via try_lock_upgrade + m.lock_upgrade(); + m.unlock_upgrade(); + REQUIRE(m.try_lock_upgrade()); + m.unlock_upgrade(); + REQUIRE(fully_released(m)); + + // upgrade -> exclusive with zero readers must complete without blocking + // (single-threaded: anything else is a deadlock) + m.lock_upgrade(); + m.unlock_upgrade_and_lock(); + m.unlock(); + REQUIRE(fully_released(m)); + + // downgrade: exclusive -> upgrade + m.lock(); + m.unlock_and_lock_upgrade(); + m.unlock_upgrade(); + REQUIRE(fully_released(m)); + + // downgrade: exclusive -> shared + m.lock(); + m.unlock_and_lock_shared(); + m.unlock_shared(); + REQUIRE(fully_released(m)); + + // downgrade: upgrade -> shared + m.lock_upgrade(); + m.unlock_upgrade_and_lock_shared(); + m.unlock_shared(); + REQUIRE(fully_released(m)); +} + +TEST_CASE("upgrade_mutex - try_lock matrix") +{ + mh::upgrade_mutex m; + + SECTION("try_lock fails while shared is held") + { + m.lock_shared(); + REQUIRE(!m.try_lock()); + m.unlock_shared(); + REQUIRE(fully_released(m)); + } + + SECTION("try_lock fails while upgrade is held") + { + m.lock_upgrade(); + REQUIRE(!m.try_lock()); + m.unlock_upgrade(); + REQUIRE(fully_released(m)); + } + + SECTION("try_lock fails while exclusive is held") + { + m.lock(); + REQUIRE(!m.try_lock()); + m.unlock(); + REQUIRE(fully_released(m)); + } + + SECTION("try_lock_shared fails while exclusive is held") + { + m.lock(); + REQUIRE(!m.try_lock_shared()); + m.unlock(); + REQUIRE(fully_released(m)); + } + + SECTION("try_lock_shared succeeds while upgrade is held with no transition") + { + m.lock_upgrade(); + REQUIRE(m.try_lock_shared()); + m.unlock_shared(); + m.unlock_upgrade(); + REQUIRE(fully_released(m)); + } + + SECTION("try_lock_upgrade fails while upgrade is held") + { + m.lock_upgrade(); + REQUIRE(!m.try_lock_upgrade()); + m.unlock_upgrade(); + REQUIRE(fully_released(m)); + } + + SECTION("try_lock_upgrade fails while exclusive is held") + { + m.lock(); + REQUIRE(!m.try_lock_upgrade()); + m.unlock(); + REQUIRE(fully_released(m)); + } + + SECTION("try_lock_upgrade succeeds while shared is held") + { + m.lock_shared(); + REQUIRE(m.try_lock_upgrade()); + m.unlock_upgrade(); + m.unlock_shared(); + REQUIRE(fully_released(m)); + } + + SECTION("try_lock_shared and try_lock_upgrade fail while a transition is pending") + { + std::atomic upgradeAcquired = false; + + m.lock_shared(); + auto_join_thread upgrader([&] + { + m.lock_upgrade(); + upgradeAcquired = true; + m.unlock_upgrade_and_lock(); // blocks until the shared owner releases + m.unlock(); + }); + scope_guard releaseShared([&] { m.unlock_shared(); }); + + REQUIRE(eventually([&] { return upgradeAcquired.load(); })); + + // The upgrader has upgrade ownership; wait until its transition is + // pending (observable as try_lock_shared failing while no exclusive + // owner can exist yet - we still hold shared). + REQUIRE(eventually([&] { return !probe_try_lock_shared(m); })); + + REQUIRE(!m.try_lock_shared()); + REQUIRE(!m.try_lock_upgrade()); + + releaseShared.invoke(); // upgrader completes + releases + upgrader.join(); + REQUIRE(fully_released(m)); + } +} + +TEST_CASE("upgrade_mutex - pending upgrade gates out new shared acquires") +{ + // THE core semantic: while unlock_upgrade_and_lock() waits for existing + // shared owners to drain, a NEW shared-lock attempt must not succeed until + // the upgrader has taken (and released) its exclusive hold. + mh::upgrade_mutex m; + event_log log; + + std::atomic upgradeAcquired = false; + std::atomic newReaderAcquired = false; + + // R (this thread): existing shared owner + m.lock_shared(); + + // U: acquires upgrade, then upgrades; the transition must block on R + auto_join_thread upgrader([&] + { + m.lock_upgrade(); + upgradeAcquired = true; + m.unlock_upgrade_and_lock(); + log.add("U:exclusive-acquired"); + // Hold exclusive briefly so a gate-jumping reader would overlap it + std::this_thread::sleep_for(50ms); + log.add("U:exclusive-releasing"); + m.unlock(); + }); + scope_guard releaseShared([&] { m.unlock_shared(); }); + + REQUIRE(eventually([&] { return upgradeAcquired.load(); })); + REQUIRE(eventually([&] { return !probe_try_lock_shared(m); })); // transition pending + + // N: new reader arriving while the transition is pending + auto_join_thread newReader([&] + { + m.lock_shared(); + log.add("N:shared-acquired"); + newReaderAcquired = true; + m.unlock_shared(); + }); + + // While the upgrader is draining: new shared attempts must not succeed + REQUIRE(!m.try_lock_shared()); + std::this_thread::sleep_for(SETTLE_TIME); + REQUIRE(!newReaderAcquired); + REQUIRE(!m.try_lock_shared()); + + // Release the pre-existing reader; the upgrader must convert to exclusive + // BEFORE the new reader gets shared ownership + log.add("R:releasing"); + releaseShared.invoke(); + + REQUIRE(eventually([&] { return newReaderAcquired.load(); })); + upgrader.join(); + newReader.join(); + + INFO("event order: " << log.str()); + REQUIRE(log.index_of("R:releasing") < log.index_of("U:exclusive-acquired")); + REQUIRE(log.index_of("U:exclusive-acquired") < log.index_of("N:shared-acquired")); + REQUIRE(log.index_of("U:exclusive-releasing") < log.index_of("N:shared-acquired")); + REQUIRE(fully_released(m)); +} + +TEST_CASE("upgrade_mutex - only one thread can hold upgrade ownership") +{ + mh::upgrade_mutex m; + + std::atomic u2Acquired = false; + std::atomic u2Upgraded = false; + + // U1 (this thread) holds upgrade: no second upgrade owner + m.lock_upgrade(); + REQUIRE(!m.try_lock_upgrade()); + + auto_join_thread u2([&] + { + m.lock_upgrade(); // blocks until U1 is completely done + u2Acquired = true; + m.unlock_upgrade_and_lock(); // U2 can itself upgrade + u2Upgraded = true; + m.unlock(); + }); + + std::this_thread::sleep_for(SETTLE_TIME); + REQUIRE(!u2Acquired); + + // U1 upgrades (no readers: immediate) - U2 must still be excluded + m.unlock_upgrade_and_lock(); + REQUIRE(!u2Acquired); + + m.unlock(); + REQUIRE(eventually([&] { return u2Acquired.load(); })); + REQUIRE(eventually([&] { return u2Upgraded.load(); })); + u2.join(); + REQUIRE(fully_released(m)); +} + +TEST_CASE("upgrade_mutex - upgraders serialize their write sections") +{ + // N upgrader threads x M iterations of lock_upgrade -> upgrade -> mutate a + // plain int -> unlock. Upgrade exclusivity + transition exclusivity make the + // read-modify-write sections mutually exclusive, so the final count is + // exact. A widened race window (yield between read and write) makes lost + // updates near-certain if two threads ever hold the write side at once. + constexpr int THREADS = 4; + constexpr int ITERATIONS = 25; + + mh::upgrade_mutex m; + int counter = 0; + + { + std::vector> threads; + for (int t = 0; t < THREADS; t++) + { + threads.push_back(std::make_unique([&] + { + for (int i = 0; i < ITERATIONS; i++) + { + m.lock_upgrade(); + m.unlock_upgrade_and_lock(); + + const int value = counter; + std::this_thread::yield(); + counter = value + 1; + + m.unlock(); + } + })); + } + } + + REQUIRE(counter == THREADS * ITERATIONS); + REQUIRE(fully_released(m)); +} + +TEST_CASE("upgrade_mutex - downgrades") +{ + mh::upgrade_mutex m; + + const auto tryLockSharedFromOtherThread = [&] + { + bool acquired = false; + auto_join_thread thread([&] + { + acquired = m.try_lock_shared(); + if (acquired) + m.unlock_shared(); + }); + thread.join(); + return acquired; + }; + const auto tryLockUpgradeFromOtherThread = [&] + { + bool acquired = false; + auto_join_thread thread([&] + { + acquired = m.try_lock_upgrade(); + if (acquired) + m.unlock_upgrade(); + }); + thread.join(); + return acquired; + }; + + SECTION("upgrade -> shared frees the upgrade slot") + { + m.lock_upgrade(); + REQUIRE(!tryLockUpgradeFromOtherThread()); + + m.unlock_upgrade_and_lock_shared(); // must not block + + REQUIRE(tryLockUpgradeFromOtherThread()); // slot free while we hold shared + REQUIRE(tryLockSharedFromOtherThread()); + REQUIRE(!m.try_lock()); // still a shared owner + + m.unlock_shared(); + REQUIRE(fully_released(m)); + } + + SECTION("exclusive -> shared lets readers join") + { + m.lock(); + REQUIRE(!tryLockSharedFromOtherThread()); + + m.unlock_and_lock_shared(); // must not block + + REQUIRE(tryLockSharedFromOtherThread()); + REQUIRE(!m.try_lock()); + + m.unlock_shared(); + REQUIRE(fully_released(m)); + } + + SECTION("exclusive -> upgrade lets readers join but not a second upgrader") + { + m.lock(); + REQUIRE(!tryLockSharedFromOtherThread()); + + m.unlock_and_lock_upgrade(); // must not block + + REQUIRE(tryLockSharedFromOtherThread()); + REQUIRE(!tryLockUpgradeFromOtherThread()); + + m.unlock_upgrade(); + REQUIRE(fully_released(m)); + } +} + +TEST_CASE("upgrade_mutex - standard RAII wrappers") +{ + mh::upgrade_mutex m; + + SECTION("std::shared_lock") + { + { + std::shared_lock lock(m); + REQUIRE(lock.owns_lock()); + REQUIRE(!m.try_lock()); + } + REQUIRE(fully_released(m)); + } + + SECTION("std::unique_lock") + { + { + std::unique_lock lock(m); + REQUIRE(lock.owns_lock()); + REQUIRE(!m.try_lock_shared()); + } + REQUIRE(fully_released(m)); + } +} + +TEST_CASE("upgrade_mutex - upgrade_lock") +{ + mh::upgrade_mutex m; + + SECTION("locking constructor") + { + { + mh::upgrade_lock lock(m); + REQUIRE(lock.owns_lock()); + REQUIRE(static_cast(lock)); + REQUIRE(lock.mutex() == &m); + REQUIRE(!m.try_lock_upgrade()); + REQUIRE(m.try_lock_shared()); // shared still allowed + m.unlock_shared(); + } + REQUIRE(fully_released(m)); + } + + SECTION("defer_lock constructor + lock()") + { + mh::upgrade_lock lock(m, std::defer_lock); + REQUIRE(!lock.owns_lock()); + REQUIRE(fully_released(m)); + + lock.lock(); + REQUIRE(lock.owns_lock()); + REQUIRE(!m.try_lock_upgrade()); + + lock.unlock(); + REQUIRE(!lock.owns_lock()); + REQUIRE(fully_released(m)); + } + + SECTION("try_to_lock constructor") + { + { + mh::upgrade_lock lock(m, std::try_to_lock); + REQUIRE(lock.owns_lock()); + } + + m.lock_upgrade(); + { + mh::upgrade_lock lock(m, std::try_to_lock); + REQUIRE(!lock.owns_lock()); + } + m.unlock_upgrade(); + REQUIRE(fully_released(m)); + } + + SECTION("adopt_lock constructor") + { + m.lock_upgrade(); + { + mh::upgrade_lock lock(m, std::adopt_lock); + REQUIRE(lock.owns_lock()); + } + REQUIRE(fully_released(m)); // destructor released the adopted ownership + } + + SECTION("deferred try_lock") + { + mh::upgrade_lock lock(m, std::defer_lock); + REQUIRE(lock.try_lock()); + REQUIRE(lock.owns_lock()); + REQUIRE(!m.try_lock_upgrade()); + } + + SECTION("move transfers ownership") + { + mh::upgrade_lock first(m); + mh::upgrade_lock second(std::move(first)); + REQUIRE(!first.owns_lock()); + REQUIRE(first.mutex() == nullptr); + REQUIRE(second.owns_lock()); + REQUIRE(second.mutex() == &m); + + mh::upgrade_lock third; + third = std::move(second); + REQUIRE(!second.owns_lock()); + REQUIRE(third.owns_lock()); + + third.unlock(); + REQUIRE(fully_released(m)); + } + + SECTION("release() abandons ownership without unlocking") + { + mh::upgrade_lock lock(m); + REQUIRE(lock.release() == &m); + REQUIRE(!lock.owns_lock()); + REQUIRE(!m.try_lock_upgrade()); // still locked + m.unlock_upgrade(); + REQUIRE(fully_released(m)); + } + + SECTION("error handling matches std::shared_lock") + { + mh::upgrade_lock unattached; + REQUIRE_THROWS_AS(unattached.lock(), std::system_error); + REQUIRE_THROWS_AS(unattached.unlock(), std::system_error); + + mh::upgrade_lock owned(m); + REQUIRE_THROWS_AS(owned.lock(), std::system_error); + } +} + +TEST_CASE("upgrade_mutex - upgrade_to_unique_lock round trip") +{ + mh::upgrade_mutex m; + + mh::upgrade_lock upgradeLock(m); + REQUIRE(upgradeLock.owns_lock()); + + { + mh::upgrade_to_unique_lock exclusiveLock(upgradeLock); + REQUIRE(exclusiveLock.owns_lock()); + REQUIRE(static_cast(exclusiveLock)); + + // exclusive ownership: no readers; source lock gave up its ownership + REQUIRE(!upgradeLock.owns_lock()); + REQUIRE(!m.try_lock_shared()); + } + + // destroyed: upgrade ownership restored to the source lock + REQUIRE(upgradeLock.owns_lock()); + REQUIRE(m.try_lock_shared()); // shared allowed alongside upgrade again + m.unlock_shared(); + REQUIRE(!m.try_lock_upgrade()); // the slot is still owned by upgradeLock + + upgradeLock.unlock(); + REQUIRE(fully_released(m)); + + // constructing from a non-owning upgrade_lock throws + mh::upgrade_lock deferred(m, std::defer_lock); + REQUIRE_THROWS_AS(mh::upgrade_to_unique_lock(deferred), std::system_error); + REQUIRE(fully_released(m)); +} + +TEST_CASE("upgrade_mutex - stress") +{ + // Readers verify a multi-word invariant (two counters that must always + // match); every mutation goes through exclusive ownership with a widened + // torn-state window in between. Any failure of exclusion shows up as a + // violation count (and as a data race under ThreadSanitizer). + constexpr int READERS = 4; + constexpr int UPGRADERS = 2; + constexpr int WRITERS = 2; + constexpr std::chrono::seconds STRESS_TIME = 2s; + + mh::upgrade_mutex m; + + struct + { + size_t a = 0; + size_t b = 0; + } data; + + std::atomic stop = false; + std::atomic violations = 0; + std::atomic totalIncrements = 0; + + const auto mutate = [&] + { + // deliberately torn: a != b is visible to any concurrent reader + data.a++; + std::this_thread::yield(); + data.b++; + }; + const auto checkInvariant = [&] + { + if (data.a != data.b) + violations++; + }; + + { + std::vector> threads; + scope_guard stopGuard([&] { stop = true; }); // even if thread creation throws + + for (int t = 0; t < READERS; t++) + { + threads.push_back(std::make_unique([&] + { + while (!stop) + { + m.lock_shared(); + checkInvariant(); + m.unlock_shared(); + } + })); + } + + for (int t = 0; t < UPGRADERS; t++) + { + threads.push_back(std::make_unique([&] + { + size_t increments = 0; + for (size_t i = 0; !stop; i++) + { + m.lock_upgrade(); + + if (i % 4 == 3) + { + // downgrade without writing; recheck under shared + m.unlock_upgrade_and_lock_shared(); + checkInvariant(); + m.unlock_shared(); + continue; + } + + m.unlock_upgrade_and_lock(); + mutate(); + increments++; + + switch (i % 3) + { + case 0: + m.unlock(); + break; + case 1: + m.unlock_and_lock_shared(); + checkInvariant(); + m.unlock_shared(); + break; + default: + m.unlock_and_lock_upgrade(); + m.unlock_upgrade(); + break; + } + } + + totalIncrements += increments; + })); + } + + for (int t = 0; t < WRITERS; t++) + { + threads.push_back(std::make_unique([&] + { + size_t increments = 0; + while (!stop) + { + m.lock(); + mutate(); + increments++; + m.unlock(); + } + + totalIncrements += increments; + })); + } + + std::this_thread::sleep_for(STRESS_TIME); + stop = true; + } // all threads joined here + + REQUIRE(violations == 0); + REQUIRE(data.a == data.b); + REQUIRE(data.a == totalIncrements); + REQUIRE(totalIncrements > 0); + REQUIRE(fully_released(m)); +}