Chapter II - Reading Notes

Essentials of thread synchronization

Four principles of thread synchronization, in order of importance:

  • The first principle is to share objects as little as possible and reduce the occasions requiring synchronization. Do not expose an object without exposing it to other threads; If you want to expose, give priority to immutable objects; Only when it is really not possible can the modifiable objects be exposed and fully protected with synchronization measures.
  • The second is to use advanced concurrent programming components.
  • Finally, when it is necessary to use the underlying synchronization primitive, only use non recursive mutexes and conditional variables. Use read-write locks with caution and do not use semaphores.
  • In addition to using atomic integers, do not write lock free code yourself, and do not use kernel level synchronization primitives.

2.1 mutex

  • The creation, destruction, locking and unlocking of mutex are encapsulated by RAII.
  • Use only non recursive mutex (i.e. non reentrant mutex).
  • The lock() and unlock() functions are not called manually, and the construction and destructor of the Gurad object on the stack are responsible for everything. The lifetime of the Gurad object is exactly equal to the critical zone.
  • Each time you construct a Guard object, think about the locks already held along the way to prevent deadlock caused by different locking sequences.

2.1. 1 use only non recursive mutex

Mutex is divided into recursive and non recursive. This is the name of POSIX. The other names are reentrant and non reentrant. The only difference between them is that the same thread can repeatedly lock the recursive mutex, but cannot repeatedly lock the non recursive mutex.

recursive mutex may hide some problems in the code. Typically, you think you can modify an object by getting a lock. Unexpectedly, the outer code has got the lock and is modifying the same object.

MutexLock mutex;
std::vector<Foo> foos;

void post(const Foo& f)
{
    MutexLockGuard lock(mutex);
    foos.push_back(f);
}

void traverse()
{
    MutexLockGuard lock(mutex);
    for(std::vector<Foo>::const_iterator it = foos.begin();
       it != foos.end(); ++it)
    {
        it->doit();
    }
}

post() locks and modifies the foos object; traverse() locks and traverses the foos vector.

If Foo::doit() indirectly calls post(), dramatic results will occur.

  1. mutex is non recursive, so it deadlocks.
  2. mutex is recursive due to push_back() may cause the vector iterator to fail and the program will crash occasionally.

If a function can be called with a lock or without a lock, it is divided into two functions:

  1. With the same name as the original function, the function is locked and the second function is called instead.
  2. Add the suffix WithLockHold to the function name. Move the original function body without lock.

Like this:

fvoid post(cost Foo& f)
{
    MutexLockGurad lock(mutex);
    postWithLockHold(f);
}

void postWithLockHold(const Foo& f)
{
    foos.push_back(f);
}

2.1. 2 deadlock

class Request
{
public:
	void process()
    {
        muduo::MutexLockGuard lock(mutex_);
        print();
    }
  	
    void print() const
    {
        muduo::MutexLockGuard lock(mutex_);
        //...
    }
    
private:
	mutable muduo::MutexLock mutex_;
};
int main()
{
    Request req;
    req.process();
}

In the above code, a deadlock occurs on line 8. This is because the same mutex is locked successively by calling Request::process() and Request::print(), causing a deadlock.

It is also easy to fix this error. Extract Request::printWithLockHold() from Request::print() and let both Request::print() and Request::process() call it.

There is an Inventory class that records the current Request object. It is easy to see that the add() and remove() members of the Inventory class below are thread safe. He used mutex to protect shared data.

class Inventory
{
public:
    void add(Request* req)
    {
		muduo::MutexLockGuard lock(mutex_);
        requests_.insert(req);
    }
    
    void remove(Request* req)
    {
        muduo::MutexLockGurad lock(mutex_);
        requests_.erase(req);
    }
    
    void printAll() const;
    
private:
    mutable muduo::MutexLock mutex_;
    std::set<Request*> requests_;
}

The interaction logic between Request class and Inventory class is very simple. When processing requests, go to g_ Add yourself to the inventory. When destructing, start from G_ Remove yourself from the inventory.

class Request
{
public:
    void process()
    {
        muduo::MutexLockGuard lock(mutex_);
        g_inventory.add(this);
    }
    ~Request()
    {
		muduo::MutexLockGuard lock(mutex_);
        sleep(1);
        g_inventory.remove(this);
    }
    void print() const
    {
        muduo::MutexLockGuard lock(mutex_);
        ...
    }
    
private:
    mutable muduo::MutexLock mutex_;
};

Another function of Inventory class is to print all known Request objects. The logic in Inventory::printAll() is OK alone, but it may cause deadlock.

void Inventory::printAll() const
{
    muduo::MutexLockGuard lock(mutex_);
    sleep(1);
    for(std::set<Request*>::const_iterator it=requests_.begin();
       it != requests_.end();++it)
    {
        (*it)->print();
    }
    printf("Inventory::printAll() unlocked\n");
}

The following program runs with a deadlock.

void threadFunc()
{
    Request* req = new Request;
    req->process();
    delete req;
}
int main()
{
	muduo::Thread thread(threadFunc);
    thread.start();
    usleep(500*1000);
    g_inventory.printAll();
    thread.join();
}

Note that the main() thread calls Inventory::printAll() and then Request::print(), while the threadFunc() thread calls Request:: ~ requests() and then Inventory::remove. The two call sequences lock the two mutex es in exactly the opposite order, resulting in deadlock.

The method to solve the deadlock is very simple. Either move print() out of the critical area of printAll() or remove() out of the critical area of ~ Request().

Here also appears the race condition of object destructor, that is, one thread is destructing the object, but another thread is calling its member function.

2.2 conditional variables

If you need to wait for a condition to be true, you should use a condition variable. A conditional variable is one or more threads waiting for a Boolean expression to be true, that is, waiting for other threads to "wake up" it.

There is only one way to use conditional variables correctly, and it is almost impossible to use them wrong. For the wait side.

  1. Must be used with mutex, and the reading and writing of this Boolean expression must be protected by mutex.
  2. wait() can only be called when mutex is locked.
  3. Put the judgment Boolean condition and wait() into the while loop.

The code is:

muduo::MutexLock mutex;
muduo::Condition cond(mutex);
std::deque<int> queue;

int dequeue()
{
    MutexLockGuard lock(mutex);
    while(queue.empty())
    {
        cond.wait();	//This step will originate from the unlock mutex and enter the wait, and will not deadlock with enqueue
        //When wait() is completed, it will automatically re lock
    }
    assert(!queue.empty());
    int top = queue.front();
    queue.pop_front();
    return top;
}

In the above code, you must use a while loop to wait for condition variables, not an if statement. The reason is spurious wakeup.

For signal/broadcast end

  1. It is not necessary to call signal when mutex is locked.
  2. Boolean expressions are usually modified before signal.
  3. Modifying Boolean expressions is usually protected with mutex.
  4. Pay attention to distinguish between signal and broadcast.

The code is:

void enqueue(int x)
{
	MutexLockGurad lock(mutex);
    queue.push_back(x);
    cond.notify();
}

Conditional variable is a very low-level synchronization primitive, which is rarely used directly. It is generally used to realize high-level synchronization measures.

Countdown latch is a synchronization method. It has two main uses.

  • The main thread initiates multiple sub threads. After these sub threads complete certain tasks, the main thread continues to execute. It is usually used for the main thread to wait for multiple child threads to complete initialization.
  • The main thread initiates multiple sub threads. The sub threads are waiting for the main thread. After the main thread completes some other tasks, it notifies all sub threads to start execution.
class CountDownLatch : boost::noncopyable
{
public:
    explicit CountDownLatch(int count);		//Count down
    void wait();							//Wait for the count value to change to 0
    void countDown();						//Count minus 1
        
private:
    mutable MutexLock mutex_;
    Condition condition_;
    int count_;
};

void CountDownLatch::wait()
{
    MutexLockGuard lock(mutex_);
    while(count_ > 0)
        condition_.wait();
}
void CountDownLatch::countDown()
{
    MutexLockGuard lock(mutex_);
    --count_;
    if(count_ == 0)
        condition_.notifyAll();
}

2.3 do not use read-write locks and semaphores

2.4 package MutexLock,MutexLockGuard,Condition

class MutexLock : boost::noncopyable
{
public:
    MutexLock():holder_(0)
    { pthread_mutex_init(&mutex_, NULL); }
    
    ~MutexLock()
    {
        assert(holder_ == 0);
        pthread_mutex_destroy(&mutex_);
    }
    
    bool isLockedBythisThread()
    { return holder_ == CurrentThread::tid(); }
    
    void assertLocked()
    { assert(isLockedByThisThread()); }
    
    void lock()
    {
        pthread_mutex_lock(&mutex);
        holder_ = CurrentThread::tid();
    }
    
    void unlock()
    {
		holder_ = 0;
        pthread_mutex_unlock(&mutex_);
    }
    
    pthread_mutex_t* getPthreadMutex()		//It is only used for Condition calling, and it is strictly prohibited to call by user code
    { return &mutex_; }
    
private:
    pthread_mutex_t mutex_;
    pid_t holder_;
};

class MutexLockGuard : boost::noncopyable
{
public:
    explicit MutexLockGuard(MutexLock& mutex) : mutex_(mutex)
    { mutex_:lock(); }
    
    ~MutexLockGuard()
    { mutex_.unlock(); }
    
 private:
    MutexLock& mutex_;
};
#define MutexLockGuard(x) static_assert(false, "missing mutex guard var name")

The last line above defines a macro to prevent the following errors in the program.

void doit()
{
    MutexLockGuard(mutex);	//Missing variable name, a temporary object is generated and destroyed immediately
    						//As a result, the critical zone was not locked
    //The correct writing is MutexLockGuard lock(mutex);
    //Critical zone
}

The following muduo::Condition class simply encapsulates Pthreads condition variable, which is also convenient to use.

class Condition : boost::noncopyable
{
public:
    explicit Condition(MutexLock& mutex) : mutex_(mutex)
    { pthread_cond_init(&pcond_, NULL); }
    
    ~Condition(){ pthread_cond_destroy(&pcond_); }
    
    void wait() { pthread_cond_wait(&pcond_, mutex_.getPthreadMutex()); }
    void notify() { pthread_cond_signal(&pcond_); }
    void notifyAll() { pthread_cond_broadcast(&pcond_); }
    
private:
    MutexLock& mutex_;
    pthread_cond_t pcond_;
};

If a class wants to contain muteslock and condition, please pay attention to their declaration order and initialization order, mutex_ Should precede condition_ Construction, and as the construction parameter of the latter.

class CountDownLatch
{
public:
    CountDownLatch(int count):mutex_(),condition_(mutex_),count_(count){}
 
private:
    mutable MutexLock mutex_;	//The order is very important. mutex first and condition later
    Condition condition_;
    int count_;
};

2.5 Singleton implementation of thread safety

template<typename T>
class Singleton : boost::noncopyable
{
public:
    static T& instance()
    {
        pthread_once(&ponce_, &Singleton::init);
        return *value_;
    }

private:
	Singleton();
    ~Singleton();
    
    static void init()
    {
        value_ = new T();
    }
    
private:
    static pthread_once_t ponce_;
    static T* value_;
};

//The static variable must be defined in the header file
template<typename T>
pthread_once_t Singleton<T>::ponce_ = PTHREAD_ONCE_INIT;

template<typename T>
T* Singleton<T>::value_ = NULL;

The Singleton above doesn't have any fancy tricks. Use pthread_once_t to ensure thread safety of lazy initialization. Thread safety is guaranteed by the Pthreads library.

The method of use is very simple.

Foo& foo = Singleton<Foo>::instance();

2.6 sleep() is not a synchronization primitive

During the normal execution of the program, if you need to wait for a known period of time, you should register a timer in the event loop, and then work in the timer callback function, because the thread is a precious shared resource and cannot be easily wasted. If you wait for an event to occur, you should use a conditional variable or IO event callback instead of polling with sleep().

If the safety and efficiency of multithreading depend on the code actively calling sleep, this is obviously a design problem. The correct way to wait for an event to occur is to use the select() equivalent or Condition, or the high-level synchronization tool.

2.8 borrowed shared_ptr implementation copy_on_write

Solution 2.1 post() and traverse() deadlock in 1.

The data structure is changed to:

typedef std::vector<Foo> FooList;
typedef boost::shared_ptr<FooList> FooListPtr;
MutexLock mutex;
FooListPtr g_foos;

On the read side, a local FooListPtr variable on the stack is used as the "Observer", which makes G_ The reference count of foos increases. The critical area of the traverse() function is 4 to 8 lines, and the shared variable G is read only once in the critical area_ Foos is much shorter than the original writing. Moreover, multiple threads calling traverse() at the same time will not block each other.

void traverse()
{
	FooListPtr foos;
    {
        MutexLockGuard lock(mutex);
        foo = g_foos;
        assert(!g_foos.unique());
    }
    for(std::vector<Foo>::const_iterator it = foos->begin();
       it != foo->end(); ++it)
    {
		it->doit();
    }
}

The key is how to write the write side post(). As described earlier, if G_ foos. If unique () is true, we can safely modify the FooList in place if g_foos.unique() is false, which means that another thread is reading the FooList. We can't modify it in place, but copy it and modify it on the copy. This avoids deadlocks.

void post(const Foo& f)
{
    printf("post\n");
    MutexLockGuard lock(mutex);
    if(!g_foos.unique())
    {
        goo_foos.reset(new FooList(*g_foos));
        printf("copy the whole list\n");
    }
    assert(g_foos.unique());
    g_foos->push_back(f);
}

Added by Akenatehm on Sun, 19 Dec 2021 17:03:03 +0200