Статьи

Чистая синхронизация с использованием ReentrantLock и Lambdas

Недавно я читал информативный пост о различиях между synchronized и ReentrantLock от ReentrantLock Paul 1 . Он подчеркивает преимущества последнего, но не скрывает некоторые недостатки, которые связаны с громоздким блоком try-finally, необходимым для правильного использования.

Соглашаясь с его заявлениями, я размышлял о мысли, которая всегда беспокоит меня, когда дело доходит до синхронизации. Оба подхода смешивают разные проблемы — синхронизацию и функциональность синхронизированного контента — что затрудняет проверку этих проблем по одному.

Будучи исследовательским типом, я выбрал решение для этой проблемы, которое я уже пробовал в прошлом. Однако в то время мне не очень нравился шаблон программирования. Это было из-за его многословности из-за анонимного класса. Но имея под рукой Java 8 и лямбда-выражения, я подумал, что стоит пересмотреть. Поэтому я скопировал часть «counter» примера Javin Paul, написал простой тестовый пример и начал рефакторинг. Это была начальная ситуация:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
class Counter {
 
  private final Lock lock;
 
  private int count;
 
  Counter() {
    lock = new ReentrantLock();
  }
 
  int next() {
    lock.lock();
    try {
      return count++;
    } finally {
      lock.unlock();
    }
  }
}

Хорошо видно уродливый блок try-finally, который создает много шума вокруг реальной функциональности 2 . Идея состоит в том, чтобы переместить этот блок в его собственный класс, который служит аспектом синхронизации, для некоторой операции, которая выполняет добавочную работу. Следующий фрагмент показывает, как может выглядеть такой вновь созданный интерфейс Operation и как он может использоваться лямбда-выражением 3 :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Counter {
 
  private final Lock lock;
 
  private int count;
 
  interface Operation<T> {
    T execute();
  }
 
  Counter() {
    lock = new ReentrantLock();
  }
 
  int next() {
    lock.lock();
    try {
      Operation<Integer> operation = () -> { return count++; };
      return operation.execute();
    } finally {
      lock.unlock();
    }
  }
}

На следующем этапе извлечения класса вводится тип Synchronizer который служит исполнителем, который обеспечивает выполнение данной Operation в надлежащих границах синхронизации:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Counter {
 
  private final Synchronizer synchronizer;
 
  private int count;
 
  interface Operation<T> {
    T execute();
  }
 
  static class Synchronizer {
 
    private final Lock lock;
 
    Synchronizer() {
      lock = new ReentrantLock();
    }
 
    private int execute( Operation<Integer> operation ) {
      lock.lock();
      try {
        return operation.execute();
      } finally {
        lock.unlock();
      }
    }
  }
 
  Counter() {
    synchronizer = new Synchronizer();
  }
 
  int next() {
    return synchronizer.execute( () -> { return count++; } );
  }
}

Если я не полностью ошибаюсь, это должно быть сделано так же, как в начальном классе. Ну, тесты были зелеными, но простые тесты JUnit обычно мало помогают в отношении параллелизма. Но с последним изменением по крайней мере возможно проверить правильную последовательность вызовов модульным тестом для обеспечения синхронизации:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public class Counter {
 
  final Synchronizer<Integer> synchronizer;
  final Operation<Integer> incrementer;
 
  private int count;
 
  public Counter( Synchronizer<Integer> synchronizer ) {
    this.synchronizer = synchronizer;
    this.incrementer = () -> { return count++; };
  }
 
  public int next() {
    return synchronizer.execute( incrementer );
  }
}

Как вы можете видеть, Operation и Synchronizer были перемещены в свои собственные файлы. Таким образом обеспечивается аспект синхронизации, который можно протестировать как отдельный модуль. Класс Counter теперь использует конструктор для внедрения экземпляра синхронизатора 4 . Кроме того, операция приращения была назначена полю с именем «incrementer». Чтобы облегчить тестирование, видимость финальных полей была открыта по умолчанию. Тест с использованием Mockito, например, для слежки за синхронизатором, теперь может обеспечить правильный вызов синхронизации, например:

1
2
3
4
5
6
7
8
9
@Test
public void synchronization() {
    Synchronizer<Integer> synchronizer = spy( new Synchronizer<>() );
    Counter counter = new Counter( synchronizer );
 
    counter.next();
 
    verify( synchronizer ).execute( counter.incrementer );
  }

Обычно я не слишком рад использованию проверки вызова метода, так как это создает очень тесную связь между модулем и контрольным примером. Но, учитывая вышеизложенные обстоятельства, это не выглядит для меня слишком плохим компромиссом. Однако я просто делаю первые разминки с Java 8 и лямбда-выражениями, и, возможно, я тоже что-то упускаю из-за параллелизма — так что вы думаете?

  1. Пример ReentrantLock в Java, разница между синхронизированным и ReentrantLock , Джавин Пол, 7 марта 2013 г.
  2. Очевидно, достаточно шума, чтобы сбить меня с толку, потому что моя первая тестовая версия не удалась …
  3. Я решил пойти с возвращаемым значением параметра типа вместо int . Таким образом, полученный механизм синхронизации может быть лучше использован повторно. Но я не уверен, что, например, автобокс здесь не критичен из-за производительности или по каким-либо причинам. Таким образом, для общего подхода, вероятно, есть еще несколько вещей, которые следует рассмотреть, которые выходят за рамки этого поста, хотя
  4. Если по какой-либо причине изменение конструктора невозможно, можно ввести делегирующий конструктор по умолчанию, который внедряет новый экземпляр Synchronizer в параметризованный, например: this( new Synchronizer() ); , Этот подход может быть приемлемым для целей тестирования