[TOC] C++ 并发实战《C++ Concurrency in Action》的学习笔记 4, 记录第六章的部分 Designing lock-based concurrent data structures. 内容是介绍有锁情况下的 data structure 的设计原则, 并且提供了 stack, queue, hash map, and linked list 的设计范例. 不仅仅考虑 thread safety, 还考虑了 concurrency efficiency 和 exception safety 等等设计思想, 非常值得品读与借鉴.
Chapter 6 Designing lock-based concurrent data structures
Some general guidelines for designing data structures for concurrency.
6.1 What does it mean to design for concurrency?
thread-safe: designing a data structure for concurrency means that multiple threads can access the data structure concurrently, either performing the same or distinct operations, and each thread will see a self-consistent view of the data structure. No data will be lost or corrupted, all invariants will be upheld, and there’ll be no problematic race conditions. 形式 In general, a data structure will be safe only for particular types of concurrent access.
单一操作: It may be possible to have multiple threads performing one type of operation on the data structure concurrently, whereas another operation requires exclusive access by a single thread.
不同操作: Alternatively, it may be safe for multiple threads to access a data structure concurrently if they’re performing different actions, whereas multiple threads performing the same action would be problematic.
Truly designing for concurrency means more than that, though: it means providing the opportunity for concurrency to threads accessing the data structure. 不是强制并发.
serialization: threads take turns accessing the data protected by the mutex; they must access it serially rather than concurrently. 序列化依次访问. 此时的原则: the smaller the protected region(越小越好), the fewer operations are serialized, and the greater the potential for concurrency.
6.1.1 Guidelines for designing data structures for concurrency
Two aspects to consider when designing data structures for concurrent access:
safe
不可见: Ensure that no thread can see a state where the invariants of the data structure have been broken by the actions of another thread.
包裹范围: Take care to avoid race conditions inherent in the interface to the data structure by providing functions for complete operations rather than for operation steps.
异常: Pay attention to how the data structure behaves in the presence of exceptions to ensure that the invariants are not broken.
死锁: Minimize the opportunities for deadlock when using the data structure by restricting the scope of locks and avoiding nested locks where possible.
构造/析构函数的特别注意: Generally, constructors and destructors require exclusive access to the data structure, but it’s up to the user to ensure that they’re not accessed before construction is complete or after destruction has started.
enabling genuine concurrent access
Can the scope of locks be restricted to allow some parts of an operation to be performed outside the lock?
Can different parts of the data structure be protected with different mutexes?
Do all operations require the same level of protection?
Can a simple change to the data structure improve the opportunities for concurrency without affecting the operational semantics?
总结成一句话: how can you minimize the amount of serialization that must occur and enable the greatest amount of true concurrency?
6.2 Lock-based concurrent data structures
设计思路: ensuring that the right mutex is locked when accessing the data and that the lock is held for the minimum amount of time.
The only member functions that aren’t safe are the constructors and destructors, but this isn’t a problem; the object can be constructed only once and destroyed only once. The user must ensure that other threads aren’t able to access the stack until it’s fully constructed and must ensure that all threads have ceased accessing the stack before it’s destroyed.
解决成员函数间的 race condition: between empty() and either of the pop() functions; separate top() and pop() member functions.
potential sources of exceptions:
资源: that’s always safe, and the use of std::lock_guard<> ensures that the mutex is never left locked.
内存不够: either copying/moving the data value throws an exception or not enough memory can be allocated to extend the underlying data structure. Either way, std::stack<> guarantees it will be safe.
越界访问: an empty_stack exception.
the call to std::make_shared might throw because it can’t allocate memory for the new object and the internal data required for reference counting, or the copy constructor or move constructor of the data item to be returned might throw when copying/moving into the freshly-allocated memory. In both cases, the C++ runtime and Standard Library ensure that there are no memory leaks and the new object (if any) is correctly destroyed. Because you still haven’t modified the underlying stack, you’re OK.
underlying 容器的操作: The call to data.pop() is guaranteed not to throw.
value=std::move(data.top());: the copy assignment or move assignment operator that can throw, rather than the construction of a new object and an std::shared_ptr instance. Again, you don’t modify the data structure until the call to data.pop(), which is still guaranteed not to throw, so this overload is exception-safe too.
empty() doesn’t modify any data, so that’s exception-safe.
deadlock: it’s sensible to require that users of the stack be responsible for ensuring this.
serialization: there’s significant contention on the stack.
because a waiting thread must either consume precious resources checking for data or the user must write external wait and notification code
6.2.2 A thread-safe queue using locks and condition variables
If you exclude the wait_and_pop() functions, the analysis you did for the stack applies just as well here.
Rather than continuously calling empty(), the waiting thread can call wait_and_pop() and the data structure will handle the waiting with a condition variable. The call to data_cond.wait() won’t return until the underlying queue has at least one element, so you don’t have to worry about the possibility of an empty queue at this point in the code, and the data is still protected with the lock on the mutex. 把上面 satck 说的 condition variable 融入了 data structure 中了.
exception safety twist: 一个线程抛出异常可能导致其他 sleep 的线程都不会被唤醒: if more than one thread is waiting when an entry is pushed onto the queue, only one thread will be woken by the call to data_cond.notify_one(). But if that thread then throws an exception in wait_and_pop(), such as when the new std::shared_ptr<> is constructed, none of the other threads will be woken.
解决办法:
data_cond.notify_all(): 效率不高.
to have wait_and_pop() call notify_one() if an exception is thrown, so that another thread can attempt to retrieve the stored value.
to move the std::shared_ptr<> initialization to the push() call and store std::shared_ptr<> instances rather than direct data values. Copying the std::shared_ptr<> out of the internal std::queue<> then can’t throw an exception, so wait_and_pop() is safe again. 改进如下:
改进点2: There’s an additional benefit: the allocation of the new instance can now be done outside the lock in push().
6.2.3 A thread-safe queue using fine-grained locks and condition variables
思路: In order to use finer-grained locking, you need to look inside the queue at its constituent parts and associate one mutex with each distinct data item.
std::shared_ptr<T> try_pop() { if(head.get()==tail) //不是与 NULL 做检查, 而是与 tail { return std::shared_ptr<T>(); } std::shared_ptr<T> constres(head->data); std::unique_ptr<node> const old_head=std::move(head); head=std::move(old_head->next); return res; } voidpush(T new_value) { std::shared_ptr<T> new_data( std::make_shared<T>(std::move(new_value))); //std::make_shared to avoid the overhead of a second memory allocation for the reference count std::unique_ptr<node> p(new node); //new dummy node tail->data=new_data; //set the data on the old dummy node to your newly allocated copy of the new_value node* const new_tail=p.get(); tail->next=std::move(p); tail=new_tail; } };
thread-safe:
push() now accesses only tail, not head, which is an improvement.
try_pop() accesses both head and tail, but tail is needed only for the initial comparison, so the lock is short-lived.
dummy node means try_pop() and push() are never operating on the same node.
同时存在 2 个 mutex 时, 谁先被访问谁先被 lock: call to get_tail() occurs inside the lock on head_mutex. If it didn’t, the call to pop_head() could be stuck in between the call to get_tail() and the lock on the head_mutex, because other threads called try_pop()(and thus pop_head()) and acquired the lock first, preventing your initial thread from making progress. This ensures that no other threads can change head, and tail only ever moves further away (as new nodes are added in calls to push()), which is perfectly safe.
push() allocates a new instance of T on the heap and a new instance of node, either of which might throw an exception. But both of the newly allocated objects are assigned to smart pointers, so they’ll be freed if an exception is thrown. Once the lock is acquired, none of the remaining operations in push() can throw an exception.
deadlock 方面: always acquires the head_mutex, and then the tail_mutex, so this will never deadlock.
效率方面:
唯一的短板: try_pop() holds the tail_mutex for only a short time, to protect a read from tail. Consequently, almost the entirety of a call to try_pop() can occur concurrently with a call to push().
the operations performed while holding the head_mutex are quite minimal; the expensive delete (in the destructor of the node pointer) is outside the lock.
这里有个应对 exception 的策略, 具体发生在 wait_and_pop(T& value) 中的 operator =: retrieved from old_head with a copy assignment to the value parameter, there’s a potential exception-safety issue. At this point, the data item has been removed from the queue and the mutex unlocked; all that remains is to return the data to the caller. But if the copy assignment throws an exception (as it might), the data item is lost because it can’t be returned to the queue in the same place. 简单地来说, 当把一个 node 取出来, 结果在赋值的时候出现异常了, 然而锁已经被 lock 住了, 导致了一个 lost node. 解决办法:
If the actual type T used for the template argument has a no-throw move-assignment operator or a no-throw swap operation, you could use that,
but you’d prefer a general solution that could be used for any type T. In this case, you have to move the potential throwing operation inside the locked region before the node is removed from the list. 把取值赋值的操作都放在锁里, 赋值异常至少还能放回去. This means you need an extra overload of pop_head() that retrieves the stored value prior to modifying the list.
A thread-safe queue with locking and waiting: try_pop() and empty()
注意 It’s an unbounded queue, 可能需要 a bounded queue 限制 queue 的长度.
6.3 Designing more complex lock-based data structures
6.3.1 Writing a thread-safe lookup table using locks
特点:
a lookup table might be modified rarely.
For the first cut at a thread-safe lookup table interface, you’ll skip the iterators. 所以可能需要自己造 map 容器.
a few basic operations on a lookup table:
Add a new key/value pair.
Change the value associated with a given key.
Remove a key and its associated value.
Obtain the value associated with a given key, if any.
others
check on whether the container is empty.
a snapshot of the complete list of keys.
a snapshot of the complete set of key/value pairs.
DESIGNING A MAP DATA STRUCTURE FOR FINE-GRAINED LOCKING
There are three common ways of implementing an associative container like your lookup table:
A binary tree, such as a red-black tree => NG, every lookup or modification has to start by accessing the root node.
A sorted array => even worse, because you can’t tell in advance where in the array a given data value is going to be, so you need a single lock for the whole array.
A hash table
只看散列表:
好处: If you again use a mutex that supports multiple readers or a single writer, you increase the opportunities for concurrency N-fold, where N is the number of buckets.
The downside is that you need a good hash function for the key.
前提: safely have a separate lock per bucket => which bucket a key belongs to is purely a property of the key and its hash function. => std::hash<> template.
The default is 19, which is an arbitrary prime number; hash tables work best with a prime number of buckets.
Each bucket is protected with an instance of std::shared_mutex.
Because the number of buckets is fixed, the get_bucket() function can be called without any locking.
exception safety
value_for doesn’t modify anything, so that’s fine; if it throws an exception, it won’t affect the data structure.
remove_mapping modifies the list with the call to erase, but this is guaranteed not to throw, so that’s safe.
add_or_update_mapping might throw in either of the two branches of if.
push_back is exception-safe and will leave the list in the original state if it throws, so that branch is fine.
assignment in the case where you’re replacing an existing value; if the assignment throws, you’re relying on it leaving the original unchanged. But this doesn’t affect the data structure as a whole and is entirely a property of the user-supplied type, so you can safely leave it up to the user to handle this.
设计 lock 住整个 map 的思路: provided you lock them in the same order every time (for example, increasing bucket index), there’ll be no opportunity for deadlock.
Obtaining contents of a threadsafe_lookup_table as std::map<>
//This function is designed to provide a iterate-over template<typename Function> voidfor_each(Function f)//the function must accept a value of type T as the sole parameter. { node* current=&head; std::unique_lock<std::mutex> lk(head.m); while(node* const next=current->next.get()) { std::unique_lock<std::mutex> next_lk(next->m); lk.unlock();//Once you have the lock on that node, you can release the lock on the previous node. f(*next->data); current=next; lk=std::move(next_lk); } }
The iteration is always one way, always starting from the head node, and always locking the next mutex before releasing the current one ==> no deadlock.