PERA LogoPERA Software Solutions GmbH
Compile time thread synchronization in C++

Compile time thread synchronization in C++

Problem

The usual way to synchronize thread access to shared data is by convention, which means the code contains a comment explaining that a certain mutex should be used to protect certain data: class Group { private: // Lock this mutex when accessing the names: std::mutex namesMutex_; std::vector< std::string > names_; }; But depending on the size of the header this comment might be overlooked and
names_
gets accessed without protecting it by locking
namesMutex_
.

Approach

To protect against such errors we need to ensure that
names_
can only be used after its corresponding mutex is locked. We achieve this by putting the data to protect in the private section of a class declaration and get a pointer to it only when calling the
lock()
method.
lock()
ensures that the mutex gets locked first and only then does it return the pointer: template < typename T > { class data_mutex { public: T *lock() { mutex_.lock(); return &data_; } void unlock() { mutex_.unlock(); } private: std::mutex mutex_; T data_; }; This design already ensures that
names_
can only be accessed after
lock()
is called: class Group { public: void addName( const string &name ) { auto names = names_.lock(); names->push_back( name ); names_.unlock(); } private: data_mutex< std::vector< std::string >> names_; }; But there are of course at least 2 problems:
  1. Not exception safe.
  2. Can still forget to call
    unlock
    at the end.
Interesting enough: Locking/unlocking is very similar to allocating/deallocating memory and we already have helper classes namely smart pointers (
shared_ptr<>
,
unique_ptr<>
) which help with freeing the memory. One very interesting feature of them is that it's possible to provide a custom deleter which makes it possible to specify how a pointer is getting deleted. In our case we can use it to automatically unlock the mutex after we are done working with the locked data: // Copyright 2015 Peter Most, PERA Software Solutions GmbH // // This file is part of the CppAidKit library. // // CppAidKit is free software: you can redistribute it and/or modify // it under the terms of the GNU Lesser General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // CppAidKit is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Lesser General Public License for more details. // // You should have received a copy of the GNU Lesser General Public License // along with CppAidKit. If not, see <http://www.gnu.org/licenses/>. #pragma once #include <mutex> #include <memory> #include <functional> namespace pera_software { namespace aidkit { namespace concurrent { /// A compile time guaranteed mutex. /** * This special mutex guarantees that the embedded resource can only be accessed after the * associated mutex has been successfully locked. */ template < typename T, typename Mutex = std::recursive_mutex > class data_mutex { public: using native_handle_type = typename Mutex::native_handle_type; using pointer = std::unique_ptr< T, std::function< void ( T * ) >>; using const_pointer = std::unique_ptr< const T, std::function< void ( const T * ) >>; /// Initialize the embedded resource with the given parameters. template < typename ... Args > data_mutex( Args && ... args ) : data_( std::forward< Args >( args ) ... ) { lockCount_ = 0; } /// Lock the mutex and return a pointer for accessing the embedded resource. pointer lock() { mutex_.lock(); return make_pointer(); } pointer try_lock() noexcept { if ( mutex_.try_lock() ) return make_pointer(); else return pointer(); } const_pointer lock() const { mutex_.lock(); return make_const_pointer(); } const_pointer try_lock() const noexcept { if ( mutex_.try_lock() ) return make_const_pointer(); else return const_pointer(); } native_handle_type native_handle() { return mutex_.native_handle(); } // These methods follow the same idea then those in shared_ptr<> where unique() is // implemented in terms of use_count(): bool is_locked() const noexcept { return lock_count() > 0; } int lock_count() const noexcept { return lockCount_; } private: void unlock( const T *data_ptr ) const { // Protect against pointer.reset( some_pointer ): if ( data_ptr == &data_ ) { --lockCount_; mutex_.unlock(); } } // If a pointer is requested, then we know that locking has succeeded and we can // increment the lock counter: pointer make_pointer() noexcept { ++lockCount_; auto unlocker = [ = ]( T *data_ptr ) { unlock( data_ptr ); }; return pointer( &data_, unlocker ); } const_pointer make_const_pointer() const noexcept { ++lockCount_; auto unlocker = [ = ]( const T *data_ptr ) { unlock( data_ptr ); }; return const_pointer( &data_, unlocker ); } T data_; mutable Mutex mutex_; mutable int lockCount_; }; } } } Some important consequences of this design: