Неблокирующий алгоритм для генерации уникальных отрицательных чисел

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

Мой алгоритм Java выглядит так:

private final Set<Integer> seen = Collections.synchronizedSet(new HashSet<Integer>());
public Integer generateUniqueNegativeIds() {
    int result = 0;
    do {
        result = random.nextInt();
        if (result > 0) {
            result *= -1;
        }
    } while (!seen.add(result));
    return result;
}

Вышеприведенная структура кода с ее спекулятивным добавлением к циклу set и «retry» заставляет меня думать, что существует эквивалентный неблокирующий алгоритм, который заменяет синхронизированный набор любым из атомарные переменные.

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

Есть ли элегантный неблокирующий эквивалент?

редактировать: ради любопытства вот ошибочная попытка использования атомарного целого числа в качестве защиты

private final AtomicInteger atomi = new AtomicInteger(0);
public Integer generateUniqueNegativeIdsWithAtomicAlgo() {
    boolean added = false;
    int result = 0;
    do {
        result = random.nextInt();
        if (result > 0) {
            result *= -1;
        }
        if (atomi.compareAndSet(0, result)) {
            added = cache.add(result);
        }   
    } while (!added);
    return atomi.getAndSet(0);
}

изменить тестовую обвязку ниже:

public static void main(String[] args) {
    final int NUMBER_OF_THREADS = 10000;
    final Set<Integer> uniques = Collections.synchronizedSet(new HashSet<Integer>());
    final List<Integer> positives = Collections.synchronizedList(new ArrayList<Integer>());
    final NegativeUniqueIdGenerator nuig = new NegativeUniqueIdGenerator();
    Thread[] workers = new Thread[NUMBER_OF_THREADS];
    long start = System.nanoTime();
    for (int i = 0; i < workers.length; i++) {
        Runnable runnable = new Runnable() {
            public void run() {
                int number = nuig.generateUniqueNegativeIds();
                if (number > 0) {
                    positives.add(number);
                }
                uniques.add(number);
            }
        };
        workers[i] = new Thread(runnable);
        workers[i].start();
    }
    for (int i = 0; i < workers.length; i++) {
        try {
            workers[i].join();
        } catch (InterruptedException ie) {}
    }
    long end = System.nanoTime();
    System.out.println(String.format("duration = %dns", (end - start)));
    System.out.println(String.format("#threads = %d", NUMBER_OF_THREADS));
    System.out.println(String.format("#uniques = %d", uniques.size()));
    System.out.println(String.format("#positives = %d", positives.size()));
    System.out.println(String.format("#duplicates = %d", NUMBER_OF_THREADS - uniques.size()));
    System.out.println(String.format("ratio = %f",
            ((double) NUMBER_OF_THREADS - uniques.size())
                    / NUMBER_OF_THREADS));
    assert uniques.size() == NUMBER_OF_THREADS;
}

person jorgetown    schedule 23.02.2009    source источник
comment
Как AtomicInteger потерпел неудачу? Должны ли идентификаторы быть непредсказуемыми или приемлема последовательность?   -  person erickson    schedule 24.02.2009
comment
Мое решение с использованием атомарных переменных терпит неудачу, если мне нужна непредсказуемость, да   -  person jorgetown    schedule 24.02.2009
comment
Хорошо, насколько непредсказуемым это должно быть? Вы пытаетесь защититься от злоумышленника, который хочет предсказать идентификаторы, или просто убедиться, что у вас есть равномерное распределение для хорошей производительности — например, избегая горячего сегмента в хеш-таблице.   -  person erickson    schedule 25.02.2009
comment
Меня больше интересует (проблема и) возможность переписать вышеприведенный алгоритм с неблокирующими конструкциями вместо блокировки/синхронизации. Таким образом, я ввел требование непредсказуемости   -  person jorgetown    schedule 25.02.2009
comment
Чтобы уточнить, это просто должно быть отрицательное, случайное, но не повторяющееся целое число, полученное потокобезопасным способом с использованием атомарных конструкций.   -  person jorgetown    schedule 25.02.2009


Ответы (8)


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

Например, при использовании 32-разрядного генератора XORShift все 2^31 отрицательных 4-байтовых целых числа будут генерироваться в «случайном» порядке перед повторением шаблона. Если вам нужно больше чисел, вы, вероятно, все равно не захотите помещать их в хеш-набор. Итак, что-то вроде этого (предупреждение: непроверенный код вне головы...):

int seed = (int) System.nanoTime();
final int origSeed = seed;

public int nextUniqueNegativeNumber() {
  int n = seed;
  do {
    n ^= (n << 13);
    n ^= (n >>> 17);
    n ^= (n << 5);
    seed = n;
    if (n == origSeed) {
      throw new InternalError("Run out of numbers!");
    }
  } while (n > 0);
  return n;
}

Я оставлю читателю возможность преобразовать "seed" для использования AtomicInteger, если требуется параллелизм...

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

Хорошо, по многочисленным просьбам атомарная версия будет выглядеть примерно так:

  AtomicInteger seed = new AtomicInteger((int) System.nanoTime());

  public int nextUniqueNegativeNumber() {
    int oldVal, n;
    do {
      do {
        oldVal = seed.get();
        n = oldVal ^ (oldVal << 13); // Added correction
        n ^= (n >>> 17);
        n ^= (n << 5);
      } while (seed.getAndSet(n) != oldVal);
    } while (n > 0);
    return n;
  }
person Neil Coffey    schedule 24.02.2009
comment
Мне очень нравится ваш подход, хотя он и не отвечает на мой вопрос о том, можно ли переписать алгоритм с использованием неблокирующих конструкций? - person jorgetown; 25.02.2009
comment
Извините, не понимал, что сделать его атомарным было сложно. На самом деле это просто тривиальное приложение, скажем, AtomicInteger. Но я добавил код. - person Neil Coffey; 25.02.2009
comment
Ваш «перетасовка» генерирует много дубликатов, даже если не сразу из-за защиты от петель. - person jorgetown; 01.03.2009
comment
Извините, я сделал опечатку в параллельной версии (см. исправление). Но в принципе метод не должен генерировать дубликаты, пока он не проверит все возможные 32-битные числа. Затем, конечно же, цикл повторяется - в конце концов, их число ограничено...! - person Neil Coffey; 01.03.2009
comment
Спасибо за руководство и ссылки на исследования @ javamex.com/tutorials/random_numbers/xorshift.shtml, Нил. Потрясающие вещи! - person jorgetown; 02.03.2009
comment
Нил, возможно, вам будет интересна и эта исследовательская статья: О генераторах случайных чисел Xorshift iro.umontreal.ca/~lecuyer/myftp/papers/xorshift.pdf Они утверждают, что генераторы Marsaglia имеют плохое равнораспределение - то, что я наблюдал в своем кросс-аппаратном/платформенном тестировании. - person jorgetown; 02.03.2009
comment
Ознакомьтесь со страницами 5 и 11 их статьи, чтобы узнать о некоторых результатах. Я обнаружил, что предложенный ими «лучший» кортеж (7, 1, 9) лучше, чем (5, 17, 13) на разных аппаратных средствах и ОС. Я наблюдал ~0,1% дубликатов при тестировании кортежа (5, 17, 13) с более чем 1000 потоков на разных платформах. - person jorgetown; 02.03.2009
comment
Спасибо, я обязательно это проверю. - person Neil Coffey; 02.03.2009

Если вас не беспокоит случайность, вы можете просто уменьшить значение счетчика, например:

private final AtomicInteger ai=new AtomicInteger(0);

public int nextID() {
  return ai.addAndGet(-1);
}

Редактировать:

Для случайных чисел вы можете просто использовать свое решение и использовать, например. ConcurrentHashMap или ConcurrentSkipListSet вместо synchronizedSet. Вы должны убедиться, что разные потоки используют разные экземпляры генератора случайных чисел и что эти генераторы не коррелированы.

person jpalecek    schedule 24.02.2009
comment
Как насчет декремента и получения()? Лучше. Но +1 за то, что он достаточно хорош. - person erickson; 24.02.2009
comment
Да, подойдет почти любой другой метод AtomicInteger (getAndAdd, getAndDecrement...). Насколько я знаю, все они одинаково эффективны на Intel. Его можно было бы реализовать и с помощью compareAndSet, но это сложнее. - person jpalecek; 24.02.2009
comment
Я думаю, что под неблокировкой автор вопроса имел в виду повторный вход и не использует синхронизацию. - person Bill K; 24.02.2009
comment
Может быть, но я так не думаю. Неблокирующий и реентерабельный — совершенно разные понятия. - person David Z; 24.02.2009
comment
Такая атомарная переменная удовлетворяет критерию неблокировки, потому что нет необходимости ждать, пока атомарная переменная станет доступной. Повторный вход также возможен, потому что любое количество путей кода может достичь атомарной операции одновременно, не нарушая гарантии атомарного обновления. - person SingleNegationElimination; 24.02.2009
comment
Правда, декрементация — очень элегантное решение, и оно меня рассмешило. Спасибо ребята. Но что, если в требованиях указана непредсказуемость? Можем ли мы написать неблокирующий алгоритм в том же духе? - person jorgetown; 24.02.2009
comment
@jorgetown: Если вам нужна непредсказуемость, вы можете просто использовать последовательные целочисленные смещения в предварительно рассчитанную таблицу отрицательных случайных чисел. - person j_random_hacker; 24.02.2009
comment
по-прежнему будут выполняться операции AS для защиты памяти на этом маршруте, но накладные расходы должны быть намного меньше, чем оспариваемая блокировка. предварительное выделение блоков целых чисел будет самым быстрым, но требует дополнительных настроек - person ShuggyCoUk; 01.03.2009

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

Почему?

По сути, вероятность того, что вы получите повторяющееся целое число, очень-очень (очень) (очень) мала, примерно 1, деленная на количество целых чисел, которые вы еще не видели. Если вы уже сгенерировали N чисел, ожидаемое время выполнения алгоритма приблизительно линейно по N с коэффициентом 1/2^32, что означает, что вам придется сгенерировать более миллиарда чисел только для того, чтобы ожидаемое время выполнения превысило 2 повторения цикла! На практике проверка набора на наличие определенного числа сделает гораздо больше для увеличения времени выполнения вашего алгоритма, чем возможность повторения цикла (ну, если вы, возможно, не используете HashSet - я забыл, каково его асимптотическое время выполнения). является).

Для чего это стоит, точное ожидаемое количество итераций цикла равно

2^64/(2^32 - N)^2

После того, как вы сгенерируете миллион чисел, получится 1,00047. Это означает, что, скажем, для генерации чисел с 1 000 001-го по 1 002 000-е вы, вероятно, получите одно повторяющееся число, всего во всех этих вызовах.

person David Z    schedule 24.02.2009
comment
Верно, но я видел много случаев, когда кто-то не додумался использовать счетчик и пошел прямо к случайному — очень мало случаев, когда людям действительно нужен был непредсказуемый ответ. - person Bill K; 24.02.2009

Элегантное решение для всех перечисленных требований, насколько я могу судить, просто уменьшает значение, начиная с -1. Я подозреваю, однако, что вы не перечислили все требования.

person Devin Jeanpierre    schedule 24.02.2009

Попробуйте следующее: http://www.javaconcurrencyinpractice.com/listings.html

person Tusc    schedule 25.02.2009

Я бы объединил ответ ОП с ответом jpalecek, чтобы дать:

private final AtomicInteger ai=new AtomicInteger(0);

public int nextID() {
    return ai.addAndGet(-1 - random.nextInt(1000));
}
person Skip Head    schedule 25.02.2009

В высокомасштабируемой библиотеке есть NonBlockingHashSet, который вы можете использовать. Просто замените свой экземпляр набора экземпляром NonBlockingHashSet, и все готово.

http://sourceforge.net/projects/high-scale-lib

person pdeva    schedule 25.02.2009

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

редактировать: (заменяет мой оригинал, потому что он намного лучше)

Только что пришел на ум вариант на основе потоков, который на самом деле довольно производительный (по крайней мере, более производительный, чем ваш оригинал). Если вы создали слабую хеш-карту с объектом-потоком в качестве «Ключа» и в качестве «Значения», поместите объект с возможностью изготовления серии, скажем, 1000 чисел из определенного диапазона.

Таким образом, вы назначаете каждому потоку собственный диапазон 1000 номеров для выделения. Когда у объекта закончатся числа, пусть он вернет недопустимое число (0?), и вы поймете, что вам нужно выделить новый диапазон для этого объекта.

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

получить текущий запущенный поток с помощью:

Thread currThread=Thread.getCurrentThread();

Также я могу ошибаться, и вам просто нужно синхронизировать метод, тогда это сработает:

int n=-1;
synchronized int getNegativeNumber() {
    return n--;
}

Я пошел вперед и написал это (иногда этот материал застревает в моей голове, пока я это не сделаю, и пока я все равно это сделал, я мог бы также опубликовать это). Непроверенный и все такое, но я почти уверен, что он должен быть близок, если не работает прямо из коробки. Всего один класс с одним статическим методом для получения уникального отрицательного числа. (О, и мне нужна была некоторая синхронизация, но она будет использоваться только 0,001% времени).

Хотелось бы, чтобы был способ создать связанный блок кода вместо встроенного, как это, не выходя за пределы сайта - извините за длину.

package test;

import java.util.WeakHashMap;

public class GenNumber {
    // Static implementation goes first.
    private static int next = -1;
    private static final int range = 1000;

    private static WeakHashMap<Thread, GenNumber> threads = new WeakHashMap<Thread, GenNumber>();

    /**
     * Generate a unique random number quickly without blocking
     * 
     * @return the random number < 0
     */
    public static int getUniqueNumber() {
        Thread current = Thread.currentThread();
        int next = 0;

        // Have to synchronize some, but let's get the very
        // common scenario out of the way first without any
        // synchronization. This will be very fast, and will
        // be the case 99.9% of the time (as long as range=1000)
        GenNumber gn = threads.get(current);
        if (gn != null) {
            next = gn.getNext();
            if (next != 0)
                return next;
        }

        // Either the thread wasn't found, or the range was
        // used up. Do the rest in a synchronized block.
        // The three lines tagged with the comment "*" have
        // the potential to collide if this wasn't synchronized.
        synchronized (threads) {
            if (gn == null) {
                gn = new GenNumber(next -= range); // *
                threads.put(current, gn); // *
                return gn.getNext(); // can't fail this time
            }
            // now we know the range has run out

            gn.setStart(next -= range); // *
            return gn.getNext();
        }
    }

    // Instance implementation (all private, nobody needs to see this)
    private int start;
    private int count;

    private GenNumber(int start) {
        setStart(start);
    }

    private int getNext() {
        if (count < range)
            return start - count;
        return 0;
    }

    private GenNumber setStart(int start) {
        this.start = start;
        return this;
    }
}

Меня просто поразило, что вместо одного большого синхронизированного блока можно было заменить 2 очень маленьких синхронизированных на разных объектах, один для "+= count" и один для .put(). Если бы столкновения все еще замедляли вас, это могло бы помочь (хотя, если бы столкновения все еще замедляли вас (ДЕЙСТВИТЕЛЬНО???), вам было бы лучше просто увеличить счет.

person Bill K    schedule 24.02.2009
comment
Как вы думаете, вы можете запустить threads.get() без блокировки, например. когда threads.put() может работать одновременно? - person jpalecek; 24.02.2009
comment
Да так как гет никак не ищет что ставится (по определению т.к. они должны быть в разных потоках). Если бы существовало возможное состояние гонки (если бы вы могли одновременно искать то, что вы вкладываете), это не сработало бы. - person Bill K; 24.02.2009
comment
Вы, кажется, недооцениваете возможность условий гонки. Простой одновременный доступ к объекту потоков, где один из доступов является записью, представляет собой состояние гонки и означает, что чтение может, например. не возвращать прошлые значения, которые находятся на карте. - person jpalecek; 25.02.2009
comment
Возможно, вы правы, забыли об обновлении хэша. Тем не менее, все сбои гарантированно обнаруживаются, поэтому вам все еще не нужна синхронизация для чтения, но это может помочь окружить ее циклом повторных попыток (попробуйте/поймайте в стороне некоторое время) - person Bill K; 25.02.2009