Подтвердить что ты не робот

Написание поточного безопасного модульного счетчика в Java

Полное выражение об отказе от ответственности: это не настоящая домашняя работа, но я отметил ее как таковую, потому что это скорее упражнение самообучения, а не "работа".

Скажем, я хочу написать простой поточно-безопасный модульный счетчик в Java. То есть, если modulo M равно 3, то счетчик должен проходить через 0, 1, 2, 0, 1, 2, … ad infinitum.

Здесь одна попытка:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicModularCounter {
    private final AtomicInteger tick = new AtomicInteger();
    private final int M;

    public AtomicModularCounter(int M) {
        this.M = M;
    }
    public int next() {
        return modulo(tick.getAndIncrement(), M);
    }
    private final static int modulo(int v, int M) {
        return ((v % M) + M) % M;
    }
}

Мой анализ (который может быть неисправен) этого кода заключается в том, что, поскольку он использует AtomicInteger, он вполне потокобезопасен даже без каких-либо явных synchronized метод/блок.

К сожалению, сам "алгоритм" не совсем "работает", потому что когда tick обтекает Integer.MAX_VALUE, next() может возвращать неправильное значение в зависимости от модуля M. То есть:

System.out.println(Integer.MAX_VALUE + 1 == Integer.MIN_VALUE); // true
System.out.println(modulo(Integer.MAX_VALUE, 3)); // 1
System.out.println(modulo(Integer.MIN_VALUE, 3)); // 1

То есть, два вызова next() возвращают 1, 1, когда обложка по модулю равна 3 и tick.

Также может возникнуть проблема с next() получением значений вне порядка, например:

  • Thread1 вызывает next()
  • Thread2 вызывает next()
  • Thread2 завершает tick.getAndIncrement(), возвращает x
  • Thread1 завершает tick.getAndIncrement(), возвращает y = x + 1 (mod M)

Здесь, запрещая описанную проблему обертывания, x и y действительно являются двумя правильными значениями, возвращаемыми для этих двух вызовов next(), но в зависимости от того, как указано поведение счетчика, можно утверждать, что они из заказ. То есть у нас есть (Thread1, y) и (Thread2, x), но, возможно, действительно нужно указать, что (Thread1, x) и (Thread2, y) являются "правильными" поведением.

Таким образом, некоторым определением слов AtomicModularCounter является потокобезопасным, но не фактически атомарным.

Итак, вопросы:

  • Правильно ли мой анализ? Если нет, укажите, пожалуйста, какие-либо ошибки.
  • Является ли последнее последнее утверждение выше, используя правильную терминологию? Если нет, что такое правильное утверждение?
  • Если проблемы, упомянутые выше, реальны, то как бы вы это исправили?
  • Можете ли вы исправить это, не используя synchronized, используя атомарность AtomicInteger?
  • Как бы вы пишете его так, что tick сам управляется диапазоном по модулю и даже не получает возможность обертывания поверх Integer.MAX_VALUE?
    • Мы можем предположить, что M по крайней мере на порядок меньше Integer.MAX_VALUE, если необходимо

Приложение

Здесь a List аналог проблемы "не по порядку".

  • Thread1 вызывает add(first)
  • Thread2 вызывает add(second)

Теперь, если список обновлен успешно с двумя добавленными элементами, но second предшествует first, который находится в конце, является ли это "потокобезопасным"?

Если это "потокобезопасный", то что это? То есть, если мы укажем, что в вышеприведенном сценарии first всегда должен быть до second, что называется этим свойством concurrency? (Я назвал это "атомарность", но я не уверен, что это правильная терминология).

Для чего это стоит, каково поведение Collections.synchronizedList в отношении этого нестандартного аспекта?

4b9b3361

Ответ 1

Насколько я вижу, вам просто нужен вариант метода getAndIncrement()

public final int getAndIncrement(int modulo) {
    for (;;) {
        int current = atomicInteger.get();
        int next = (current + 1) % modulo;
        if (atomicInteger.compareAndSet(current, next))
            return current;
    }
}

Ответ 2

Я бы сказал, что, помимо обертывания, все в порядке. Когда два вызова метода эффективно одновременно, вы не можете гарантировать, что произойдет первым.

Код по-прежнему является атомарным, потому что, что на самом деле происходит первым, они не могут вмешиваться друг в друга.

В принципе, если у вас есть код, который пытается полагаться на порядок одновременных вызовов, у вас уже есть состояние гонки. Даже если в вызывающем коде один поток попадает в начало вызова next() перед другим, вы можете представить, что он подходит к концу своего временного фрагмента до того, как он попадает в вызов next(), что позволяет второму потоку войди туда.

Если вызов next() имел какой-либо другой побочный эффект - например, он печатал "Начиная с потока (thread id)", а затем возвращал следующее значение, тогда он не был бы атомарным; у вас будет наблюдаемая разница в поведении. Как бы то ни было, я думаю, что все в порядке.

Можно подумать об отношении обертывания: вы можете сделать счетчик последним намного длиннее перед упаковкой, если вы используете AtomicLong:)

EDIT: Я просто подумал о том, как избежать проблем с упаковкой во всех реалистичных сценариях:

  • Определите некоторое количество M * 100000 (или что-то еще). Это должно быть достаточно большим, чтобы его нельзя было слишком часто удалять (поскольку это снижает производительность), но достаточно мала, чтобы вы могли ожидать, что цикл "фиксации" ниже будет эффективен, если слишком много потоков добавили к тику, чтобы вызвать его завернуть.
  • Когда вы получите значение с помощью getAndIncrement(), проверьте, больше ли это число. Если да, перейдите в "цикл сокращения", который будет выглядеть примерно так:

    long tmp;
    while ((tmp = tick.get()) > SAFETY_VALUE))
    {
        long newValue = tmp - SAFETY_VALUE;
        tick.compareAndSet(tmp, newValue);
    }
    

В основном это говорит: "Нам нужно вернуть значение в безопасный диапазон, уменьшив несколько кратных модуля" (так, чтобы он не менял значение mod M). Он делает это в трудном цикле, в основном разрабатывая новое значение, но только делая изменения, если ничто иное не изменило значение между ними.

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

Ответ 3

Что касается проблемы атомарности: я не считаю, что сам счетчик может обеспечить поведение, гарантирующее семантику, которую вы подразумеваете.

Я думаю, что у нас есть поток, выполняющий некоторую работу

  A - get some stuff (for example receive a message)
  B - prepare to call Counter
  C - Enter Counter <=== counter code is now in control
  D - Increment
  E - return from Counter <==== just about to leave counter control
  F - application continues

Посредничество, которое вы ищете, относится к порядку идентификации "полезной нагрузки", установленному в A.

Например, два потока каждый читают сообщение - один читает X, один читает Y. Вы хотите убедиться, что X получает первое приращение счетчика, Y получает второе, хотя оба потока работают одновременно и могут быть запланированы произвольно через 1 или более процессоров.

Следовательно, любое упорядочение должно быть наложено на все этапы A-F и принудительно выполняется с помощью некоторого счетчика concurrency вне счетчика. Например:

pre-A - Get a lock on Counter (or other lock)
  A - get some stuff (for example receive a message)
  B - prepare to call Counter
  C - Enter Counter <=== counter code is now in control
  D - Increment
  E - return from Counter <==== just about to leave counter control
  F - application continues
post- F - release lock

Теперь у нас есть гарантия за счет некоторых parallelism; потоки ждут друг друга. Когда строгий порядок является требованием, это ограничивает concurrency; это общая проблема в системах обмена сообщениями.

Что касается вопроса о списке. Безопасность резьбы следует рассматривать с точки зрения гарантий интерфейса. Существует абсолютная минимальная переоценка: Список должен быть устойчивым перед одновременным доступом из нескольких потоков. Например, мы могли бы представить небезопасный список, который мог бы зайти в тупик или оставить список неправильно связанным, чтобы любая итерация зацикливалась навсегда. Следующее требование состоит в том, что мы должны указывать поведение, когда два потока обращаются в одно и то же время. Там много случаев, здесь несколько

a). Two threads attempt to add
b). One thread adds item with key "X", another attempts to delete the item with key "X"
C). One thread is iterating while a second thread is adding

Обеспечение того, чтобы реализация четко определяла поведение в каждом случае, она поточно-безопасна. Интересный вопрос: какое поведение удобно.

Мы можем просто синхронизировать в списке и, следовательно, легко дать хорошо понятное поведение для a и b. Однако это происходит за счет стоимости parallelism. И я утверждаю, что это не имело значения для этого, поскольку вам все еще нужно синхронизировать на каком-то более высоком уровне, чтобы получить полезную семантику. Поэтому у меня будет спецификация интерфейса, в которой говорится: "Добавляется в любом порядке".

Что касается итерации - это сложная проблема, посмотрите, что обещают Java-коллекции: не так много!

Эта статья, в которой обсуждаются коллекции Java, может быть интересной.

Ответ 4

Атомный (как я понимаю) относится к тому факту, что промежуточное состояние не наблюдается снаружи. atomicInteger.incrementAndGet() является атомарным, а return this.intField++; не является, в том смысле, что в первом случае вы не можете наблюдать состояние, в котором целое число было увеличено, но еще не возвращено.

Что касается безопасности потоков, авторы Java Concurrency на практике предоставляют одно определение в своей книге:

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

(Мое личное мнение следует)


Теперь, если у нас есть список обновляется успешно с двумя элементами добавлено, но второе - прежде, который находится в конце, заключается в том, что "поток безопасный"?

Если thread1 вошел в набор записей объекта mutex (в случае Collections.synchronizedList() самого списка) перед thread2, гарантируется, что first находится впереди, чем second в списке после обновления. Это связано с тем, что ключевое слово synchronized использует справедливую блокировку. Тот, кто сидит впереди очереди, сначала начинает делать вещи. Яркие замки могут быть довольно дорогими, и вы также можете иметь несанкционированные блокировки в java (с помощью утилиты java.util.concurrent). Если вы сделаете это, тогда нет такой гарантии.

Однако платформа Java не является вычислительной платформой реального времени, поэтому вы не можете предсказать, сколько времени потребуется для выполнения кода. Это означает, что если вы хотите first опережать second, вам необходимо обеспечить это явно в java. Это невозможно обеспечить путем "контроля времени" вызова.

Теперь, что такое потокобезопасное или небезопасное здесь? Я думаю, это просто зависит от того, что нужно сделать. Если вам просто нужно избегать поврежденного списка, и неважно, является ли first первым или second первым в списке, для того, чтобы приложение работало корректно, достаточно просто избежать повреждения, чтобы установить thread- безопасность. Если это не так, это не так.

Итак, я думаю, что безопасность потока не может быть определена в отсутствие конкретной функциональности, которую мы пытаемся достичь.

Знаменитый String.hashCode() не использует какой-либо конкретный "механизм синхронизации", предоставляемый в java, но он по-прежнему является потокобезопасным, потому что можно безопасно использовать его в своем приложении. не беспокоясь о синхронизации и т.д.

Известный трюк String.hashCode():

int hash = 0;

int hashCode(){
    int hash = this.hash;
    if(hash==0){
        hash = this.hash = calcHash();
    }
    return hash;
 }