Лекция 7.1. Мониторы и ожидание
Ожидание
Пусть операция над объектом это функция \(f(S, P) = (S', R)\). Раньше операции были всюду определены на паре \((S, P)\), то есть если операцию нельзя выполнить, то это исключение. Однако в общем случае операции могут быть частично определены, т.е. операция не может завершиться и ждет.
Например, очередь ограниченного размера с ожиданием:
put(item)- кладет элемент в очередь, если есть место (иначе ждет)take(): item- забирает элемент из очереди, если очередь не пустая (иначе ждет)
Это часто происходит в паттерне producer-cosumer (привет, ГК).
Пусть в исполнении может не быть \(res(A)\). Тогда будем называть исполнение линеаризуемым, если для незавершенных операций можно:
- или добавить ответы,
- или выкинуть их из исполнения
Так, чтобы получилось допустимое последовательное исполнение.
Мониторы
Это mutex + условные переменные.
В jvm у каждого объекта есть монитор с одной условной переменной, wait, notify, notifyAll работают с ней.
Циклическая очередь на массиве
public class BlockingQueue<T> {
private final T[] items; // элементы
private final int n; // == items.length
private int head; // голова
private int tail; // хвост
public synchronized int size() {
return (tail - head + n) % n;
}
// не ждущий
public synchronized T poll() {
if (head == tail) return null;
T result = items[head];
items[head] = null;
head = (head + 1) % n;
return result;
}
// ждущий
public synchronized T take() throws InterruptedException /* позже поговорим */ {
while (head == tail) wait(); // ждем
// wait может сам по себе проснуться, поэтому его надо делать в цикле
T result = items[head];
items[head] = null;
head = (head + 1) % n;
return result;
}
}
Метод wait - часть монитора, освобождает блокировку и ждёт сигнала о пробуждении. Сингал посылается через notify и notifyAll. Оба могут быть использованы только в критической секции.
// не ждущий
public synchronized boolean offer(T item) {
int next = (tail + 1) % n;
if (next == head) return false;
items[tail] = item;
if (head == tail) notifyAll();
tail = next;
return true;
}
public synchronized void put(T item) throws Inter...Ex... {
while (true) { // пока не подходящее состояние
int next = (tail + 1) % n;
if (next == head) { wait(); continue; }
items[tail] = item;
if (head == tail) notifyAll();
tail = next;
return;
}
}
Нам не важно, где в коде вызван notifyAll.
notify более точечный, потому что можно пробуждать только один поток. В нашем примере это не сработает, т.к. put может пробудить другой put поток, а нам нужно будить take потоки. Однако в ReentrantLock можно заводить условные переменные через lock.newCondition(). На других языках условные переменные используются только так.
class BlockingQueue<T>(private val n: Int) {
private val items = arrayOfNulls<Any>(n)
private var head = 0
private var tail = 0
private val lock = ReentrantLock()
private val notEmpty = lock.newCondition()
private val notFull = lock.newCondition()
}
fun take(): T = lock.withLock {
while (head == tail) notEmpty.await() // ждем
val result = items[head] as T
items[head] = null
// ТУТ БАГ!
if ((tail + 1) % n == head) notFull.signal() // была полна
head = (head + 1) % n
result // вернули из withLock
}
fun put(item: T): Unit = lock.withLock {
while (true) { // пока не подходящее состояние
val next = (tail + 1) % n
if (next == head) { notFull.await(); continue }
items[tail] = item
// ТУТ БАГ!
notEmpty.signal() // надо посылать один сигнал
tail = next
return@withLock
}
}
Два сигнала от разных take могут сигнальнуть одному и тому же put. Нам не гарантировано, что сигнальнутый поток сразу проснется, поэтому он может проснуться после обоих сигналов и тогда другой put не проснется, а должен бы.
Простой способ починить: слать сигнал без условия. В худшем случае сигнал быстро отработает, т.к. будить некого.
Переключение контекста (разбудить поток) — очень дорого.
Interrupt
У каждого потока есть флаг interrupted. Он выставляется методом Thread.interrupt, его проверяют методы wait/await и если он выставлен, то сбрасывают его и кидают InterruptedException. Таким образом можно кооперативно прекращать ожидание.
Если мы не знаем, что делать с InterruptedException, то надо писать так:
public T takeOrNull() {
try {
return take();
} catch (InterruptedException e) {
// перевыставим флаг interrupted
Thread.currentThread().interrupt();
return null;
}
}
public class DoSomethingThread<T> extends Thread {
private final BlockingQueue<T> queue; // задачи
private volatile boolean closed; // флаг останова
public void close() {
closed = true; // ставим флаг останова (сначала!)
interrupt(); // чтобы прервать ожидания
}
@Override
public void run() {
try {
while (!closed) {
T item = queue.take();
doSomething(item);
}
} catch (InterruptedException e) {
// а вот здесь можем проигнорировать -- уже выходим
}
}
}
Обновляемое значение
Реализация с блокировкой:
class DataHolder<T> {
private var value: T? = null
private val lock = ReentrantLock()
fun update(item: T) = lock.withLock {
value = item
}
fun remove(): T? = lock.withLock {
value.also { value = null }
}
}
Реализация с ожиданием:
private val updated = lock.newCondition()
fun take(): T = lock.withLock {
while (value == null) updated.await()
value!!.also { value = null }
}
fun update(item: T) = lock.withLock {
value = item
updated.signal()
}
Реализация без блокировки, но не ждущими методами:
class DataHolder<T> {
private val v = atomic<T?>(null)
fun update(item: T) {
v.value = item // volatile write
}
fun remove(): T? {
v.loop { cur ->
if (cur == null) return null
if (v.compareAndSet(cur, null)) return cur
}
}
}
Ожидание без блокировки через park:
class TakerThread<T> : Thread() {
// ...
fun take(): T {
assert(Thread.currentThread() == this)
v.loop { cur ->
if (cur == null) {
LockSupport.park()
if (interrupted()) // ручная проверка флага
throw InterruptedException()
return@loop // continue loop
}
if (v.compareAndSet(cur, null)) return cur
}
}
fun update(item: T) {
v.value = item // volatile write
LockSupport.unpark(this)
}
}
Поток будит только сам себя. Кроме того, unpark будит не только уже спящий поток, но и поток который уснет, т.е. park будет no-op.
Если мы хотим сделать ожидание из многих потоков, то надо использовать AbstractQueuedSynchronizer, правда он вообще-то предназначен для написания блокировок .
inner class Sync : AbstractQueuedSynchronizer() {
override fun tryAcquire(arg: Int): Boolean {
val cur = v.value ?: return false
if (!v.compareAndSet(cur, null)) return false
// надо как-то вернуть значение отсюда, будем держать в поле
results[arg] = cur
return true
}
// всегда "освобождаем" -- будим следующего
override fun tryRelease(arg: Int): Boolean = true
}
private val sync = Sync()
fun update(item: T) {
v.value = item // volatile write
sync.release(0) // шлем сигнал
}
fun take(): T {
val arg = reserveResultsSlot() // приходится крутиться
sync.acquireInterruptibly(arg) // ждет внутри
// нужна перепроверка чтобы не потерять unpark
if (v.value != null) sync.release(0)
return releaseResultsSlot(arg)
}