18 марта 2010 г.

Зачем synchronized вокруг wait/notify?

Еще один из вопросов, который мучил меня во время знакомства с java threading, и так и остался, в то время, не отвеченным. Зачем сановские инженеры спроектировали wait/notify так, что они обязательно требуют на входе захваченной блокировки? Причем на входе они ее требуют, но внутри себя они ее отпускают -- что-то очень хитрое стояло за таким решением, что-то, что я не мог понять.

Ответ оказался довольно прост. Другое поведение не имеет смысла, потому что не дает возможность реализовать то, для чего нужен wait/notify.

Зачем нужен wait? Точнее -- каков сценарий его использования? Если мы хотим просто приостановить поток -- есть Thread.sleep(). А wait нужен тогда, когда мы ждем какого-то события. Еще точнее, мы ждем выполнения какого-то условия, за которое отвечает какой-то другой поток. Но это означает, что у нашего потока с этим другим потоком есть общее, разделяемое состояние (shared state). Второй поток это состояние меняет, и в какой-то момент оно становится "подходящим" для нас, и мы хотим об этом узнать. И wait/notify это всего лишь инструмент, который дает нам такую возможность. Но если у нас есть разделяемое состояние -- нам просто необходима блокировка в обоих потоках, чтобы избежать проблем с data race и memory visibility. Сами-то по себе методы wait/notify можно организовать без требования блокировки -- но они будут бесполезны

На конкретном примере: вот как выглядит типичный код с wait/notify (представим, что секции synchronized не обязательны)
//общие переменные 
    boolean condition = false;
    final Object event = new Object();
    ...
    //первый поток
    while(!condition){
        event.wait();
    }
    ...
    //второй поток
    condition = true;
    event.notify();

Что помешает второму потоку вклиниться между строчками 7 и 8 -- когда первый уже решил, что он должен ждать, но еще не вызвал метод wait? В этом случае notify() вызванный вторым уйдет в пустоту (пока еще никто ничего не ждет), а wait(), вызванный первым никогда не пробудится -- некому больше будить (забудем пока про внезапные пробуждения). Другой вопрос -- кто гарантирует, что обновленное вторым потоком значение condition=true будет увидено первым? Никаких memory barrier-ов здесь нет, первый поток спокойно может закэшировать condition хоть в регистрах процессора, и быть свято уверенным, что оно все еще false. Еще можно вообразить разнообразные insruction reordering, в ходе которых компилятор может решить переставить condition = true после event.notify(), например.

Ок, договорились: синхронизация необходима. Но зачем так плотно привязывать wait/notify к synchronized? Спроектировали бы wait/notify независимо от синхронизации, и просто указывали бы в recommended practices что правильно писать так-то. Были бы очевидные бенефиты -- сейчас, например, старый-добрый Object.wait/notify работает только со старым-добрым synchronized, а новый Lock.lock()/unlock() только с новым же Condition.await/signal -- а между собой они не работают, что, в общем-то, странно. Казалось бы -- какая разница, каким методом обеспечивать синхронизацию разделяемого состояния между потоками?

Ан нет, без плотного связывания не получится. wait обязан знать многое о мониторе синхронизации, с помощью которого согласовывается разделяемое состояние -- потому что wait должен уметь этот монитор сначала отпустить на входе, а потом захватить заново -- на выходе. Как минимум, wait должен иметь доступ к этому монитору.

Вот мы и приходим к той реализации, что имеем. Сначала нужно захватить монитор какого-нибудь объекта, чтобы обеспечить согласованность состояния condition. Потом можно вызвать wait(), и он должен захваченный монитор отпустить -- иначе как другой поток сможет изменять условие? Но wait нужно вызвать на том же объекте -- иначе как wait() узнает, какой именно монитор из 10 захваченных текущим потоком выше по стеку ему надо освободить? Перед notify нужно захватить тот же самый монитор, чтобы синхронизировать изменения в condition между потоками. Ну и очевидно, что notify() нужно вызывать на том же объекте, чтобы он знал, какой wait ему нужно будить. Такой вот расклад

11 комментариев:

  1. Руслан, спасибо за хорошую статью!

    ОтветитьУдалить
  2. Пожалуйста, рад что пригодилось

    ОтветитьУдалить
  3. Супер! Спасибо!
    Только есть наивный вопрос. Почему condition не volatile? Как synchronized препятствует кэшированию переменной?

    ОтветитьУдалить
    Ответы
    1. Ну как бы monitor enter/exit это synchronization action, и monitorExit synchronizedWith monitorEnter. Это и дает основание утверждать, что вы увидите во втором потоке значение, записанное в первом. Это очень вкратце если )

      Удалить
    2. А, я понял! При соблюдении этих условий, memory model со своим happens-before гарантирует синхронизацию всех кэшей с памятью.

      Удалить
    3. А мне вот, всё равно не понятно, почему нельзя использовать volatile, так как это препятствует как кэшированию объекта так и процедуре reordering.
      Так почему же не volatile?

      Удалить
    4. Когда переменная одна, и извещение однократное -- да, volatile достаточно. Если состояние не ограничивается одной переменной, и/или ожидание-извещение повторяется многократно -- умучаетесь реализовывать это корректно на volatile-ах.

      Удалить
  4. Немножко не понятно: вы объясняли зачем привязывать жёстко к synchronized:
    Но зачем так плотно привязывать wait/notify к synchronized?
    "потому что wait должен уметь этот монитор сначала отпустить на входе" какой монитор?? Если без синхронизации то нету никакого монитора.

    ОтветитьУдалить
    Ответы
    1. Область применимости wait/notify без синхронизации крайне узка. По-сути, я сомневаюсь, что найдется больше пары случаев, где они полезны, и отсутствие синхронизации даст хоть какой-то профит.

      Удалить
  5. На мой взгляд самый логичное из объяснений:
    Взято со stackoverflow:
    Что поток который находиться в состоянии wait, попросту может не увидеть сообщение notify, из другого потока без синхронизации.

    ОтветитьУдалить
    Ответы
    1. Мне здесь как раз не понятно, что значит "не увидеть"? Какой конкретно сценарий подразумевается?

      Удалить