В Java мога ли да разчитам на това, че референтното присвояване е атомарно, за да реализирам копиране при запис?

Ако имам несинхронизирана java колекция в многонишкова среда и не искам да принуждавам читателите на колекцията да синхронизират[1], е решение, при което синхронизирам писателите и използвам атомарността на осъществимо ли е референтното задание? Нещо като:

private Collection global = new HashSet(); // start threading after this

void allUpdatesGoThroughHere(Object exampleOperand) {
  // My hypothesis is that this prevents operations in the block being re-ordered
  synchronized(global) {
    Collection copy = new HashSet(global);
    copy.remove(exampleOperand);
    // Given my hypothesis, we should have a fully constructed object here. So a 
    // reader will either get the old or the new Collection, but never an 
    // inconsistent one.
    global = copy;    
  }
}

// Do multithreaded reads here. All reads are done through a reference copy like:
// Collection copy = global;
// for (Object elm: copy) {...
// so the global reference being updated half way through should have no impact 

Превъртането на вашето собствено решение изглежда често се проваля в този тип ситуации, така че ще ми е интересно да знам други модели, колекции или библиотеки, които мога да използвам, за да предотвратя създаването на обекти и блокирането за моите потребители на данни.


[1] Причините са голяма част от времето, прекарано в четене в сравнение с писане, съчетано с риска от въвеждане на блокирания.


Редактиране: Много добра информация в няколко от отговорите и коментарите, някои важни точки:

  1. В кода, който публикувах, имаше грешка. Синхронизирането на глобално (лошо наречена променлива) може да не успее да защити синхронизирания блок след размяна.
  2. Можете да коригирате това чрез синхронизиране на класа (преместване на ключовата дума synchronized към метода), но може да има други грешки. По-безопасно и по-поддържано решение е да използвате нещо от java.util.concurrent.
  3. Няма "евентуална гаранция за последователност" в кода, който публикувах, един от начините да се уверите, че читателите ще видят актуализациите от авторите, е да използвате ключовата дума volatile.
  4. Като се замисля, общият проблем, който мотивира този въпрос, беше опитът да се приложат lock free reads със заключени записи в java, но моят (решен) проблем беше с колекция, което може да бъде ненужно объркващо за бъдещите читатели. Така че, в случай че не е очевидно, кодът, който публикувах, работи, като позволява на един автор наведнъж да извършва редакции на „някакъв обект“, който се чете незащитен от множество нишки на четец. Ангажиментите на редакцията се извършват чрез атомарна операция, така че читателите могат да получат само „обекта“ преди или след редакцията. Когато/ако нишката на четеца получи актуализацията, това не може да се случи по средата на четене, тъй като четенето се извършва на старото копие на „обекта“. Просто решение, което вероятно е било открито и се е оказало неработещо по някакъв начин преди наличието на по-добра поддръжка на едновременност в java.

person MilesHampson    schedule 15.08.2012    source източник
comment
Това изглежда като нещо като колекциите в java .util.concurrent ще се справи добре.   -  person DaoWen    schedule 15.08.2012
comment
Разгледах тези структури от данни, но бях загрижен за тяхната ефективност на получаване и итератор в сравнение с моето решение по-горе. Вероятно би си струвало да сравнявам това.   -  person MilesHampson    schedule 15.08.2012
comment
и е интересно да се види, че понякога те също могат да се объркат ужасно stackoverflow.com/questions/3292577/. Не че казвам, че не трябва да го използвате, просто понякога трябва да си напишете домашното и да разберете как се изпълнява вашият код.   -  person MilesHampson    schedule 16.08.2012
comment
MilesHampson - Ситуацията, описана в този въпрос, би се случила основно без значение какъв тип конструкции за паралелност използват. Внезапното прекратяване на нишка, без да й се даде шанс да изчисти след себе си (т.е. освобождаване на ключалките, които държи), ще бъде катастрофално във всяка ситуация. Мисля, че фактът, че пакетът, участващ в убиването на нишки, се нарича Unsafe, трябва да е достатъчно голямо червено знаме, за да знаете, че могат да се случат лоши неща, ако го използвате!   -  person DaoWen    schedule 16.08.2012


Отговори (5)


Вместо да се опитвате да внедрите свое собствено решение, защо не използвате ConcurrentHashMap като ваш набор и просто да зададете всички стойности на някаква стандартна стойност? (Константа като Boolean.TRUE ще работи добре.)

Мисля, че тази реализация работи добре със сценария много читатели малко писатели. Има дори конструктор, който ви позволява да зададете очакваното "ниво на едновременност".

Актуализация: Виър предложи използването на Collections.newSetFromMap помощен метод за превръщане на ConcurrentHashMap в Set. Тъй като методът приема Map<E,Boolean>, моето предположение е, че той прави същото със задаване на всички стойности на Boolean.TRUE зад кулисите.


Актуализация: Обръщане към примера на плаката

Вероятно с това ще се спра, но все още съм любопитен как моето минималистично решение може да се провали. – Майлс Хемпсън

Вашето минималистично решение ще работи добре с малко промени. Притеснението ми е, че въпреки че сега е минимално, може да стане по-сложно в бъдеще. Трудно е да запомните всички условия, които приемате, когато правите нещо безопасно за нишките – особено ако се връщате към кода седмици/месеци/години по-късно, за да направите привидно незначителна настройка. Ако ConcurrentHashMap прави всичко необходимо с достатъчна производителност, тогава защо не го използвате вместо това? Всички неприятни подробности за паралелността са капсулирани и дори след 6 месеца ще ви е трудно да го объркате!

Имате нужда от поне едно ощипване, преди текущото ви решение да работи. Както вече беше посочено, вероятно трябва да добавите модификатора volatile към декларацията на global. Не знам дали имате опит в C/C++, но бях много изненадан, когато научих, че семантиката на volatile в Java всъщност са много по-сложни от в C. Ако планирате да правите много едновременно програмиране в Java, тогава би било добра идея да се запознаете с основите на модела на паметта на Java. Ако не направите препратката към global препратка volatile, тогава е възможно никоя нишка никога да не види промени в стойността на global, докато не се опита да я актуализира, в който момент влизането в блока synchronized ще изчисти локалния кеш и ще получи актуализираната референтна стойност.

Но дори и с добавянето на volatile все още има огромен проблем. Ето проблемен сценарий с две нишки:

  1. Започваме с празния набор или global={}. И двете нишки A и B имат тази стойност в тяхната локална кеширана памет за нишки.
  2. Получаването на нишка A получава заключването synchronized на global и започва актуализацията, като прави копие на global и добавя новия ключ към набора.
  3. Докато Thread A все още е в блока synchronized, Thread B чете своята локална стойност от global в стека и се опитва да влезе в блока synchronized. Тъй като Thread A в момента е в блоковете на Thread B на монитора.
  4. Нишка A завършва актуализацията, като задава препратка и излиза от монитора, което води до global={1}.
  5. Нишката B вече може да влезе в монитора и прави копие на набора global={1}.
  6. Нишка A решава да направи друга актуализация, чете локалната си препратка global и се опитва да влезе в блока synchronized. Тъй като нишка B в момента държи заключването на {}, няма заключване на {1} и нишка A успешно влиза в монитора!
  7. Нишката A също прави копие на {1} за целите на актуализирането.

Сега нишките A и B са вътре в блока synchronized и имат идентични копия на набора global={1}. Това означава, че една от техните актуализации ще бъде загубена! Тази ситуация е причинена от факта, че синхронизирате върху обект, съхранен в препратка, която актуализирате във вашия synchronized блок. Винаги трябва да сте много внимателни кои обекти използвате за синхронизиране. Можете да коригирате този проблем, като добавите нова променлива, която да действа като ключалка:

private volatile Collection global = new HashSet(); // start threading after this
private final Object globalLock = new Object(); // final reference used for synchronization

void allUpdatesGoThroughHere(Object exampleOperand) {
  // My hypothesis is that this prevents operations in the block being re-ordered
  synchronized(globalLock) {
    Collection copy = new HashSet(global);
    copy.remove(exampleOperand);
    // Given my hypothesis, we should have a fully constructed object here. So a 
    // reader will either get the old or the new Collection, but never an 
    // inconsistent one.
    global = copy;    
  }
}

Този бъг беше достатъчно коварен, че никой от другите отговори все още не го адресира. Тези видове луди подробности за паралелността ме карат да препоръчам използването на нещо от вече дебъгваната библиотека java.util.concurrent, вместо да се опитвате да сглобите нещо сами. Мисля, че горното решение ще работи, но колко лесно би било да го прецакам отново? Това би било много по-лесно:

private final Set<Object> global = Collections.newSetFromMap(new ConcurrentHashMap<Object,Boolean>());

Тъй като препратката е final, не е нужно да се притеснявате за нишки, използващи остарели препратки, и тъй като ConcurrentHashMap се справя вътрешно с всички неприятни проблеми с модела на паметта, не е нужно да се тревожите за всички неприятни подробности за мониторите и бариерите на паметта!

person DaoWen    schedule 15.08.2012
comment
Цитирам... Подобно на Hashtable, но за разлика от HashMap, този клас не позволява null да се използва като ключ или стойност. ;) - person obataku; 15.08.2012
comment
@veer - Благодаря! Промених го, за да препоръчам Boolean.TRUE вместо това. - person DaoWen; 15.08.2012
comment
Между другото, може да намерите за изгодно да използвате Collections.newSetFromMap. - person obataku; 15.08.2012
comment
@veer - Това е наистина интересно! Добре е да знам, че е там. Вече гласувах за вашия отговор, тъй като всъщност отговаря на всички въпроси на автора. Започнах да пиша публикацията си, преди да забележа вашите редакции в долната част относно използването на java.util.concurrent, в противен случай дори не бих си дал труда да отговоря. Явно си знаеш работата! - person DaoWen; 15.08.2012
comment
Благодаря! Направихте добри предложения за алтернативи, което вероятно е по-ценно от отговорите на директните му въпроси относно пускането на собствени, така или иначе... ;) - person obataku; 15.08.2012
comment
Вероятно с това ще се спра, но все още съм любопитен как моето минималистично решение може да се провали. - person MilesHampson; 15.08.2012
comment
MilesHampson - Актуализирах отговора си с хубаво дълго есе за проблемите в решението, което публикувахте във вашия въпрос. Наслади се! - person DaoWen; 15.08.2012
comment
Благодаря, тази редакция беше точно нещото, което търсех (между другото можете да коригирате този проблем, като просто преместите ключовата дума synchronized към метода, вместо да добавяте нов монитор). Съгласен съм, толкова е трудно да се разсъждава за поведението на паралелния код, че трябва да имате наистина добра причина да не използвате помощните програми на Java. И благодаря за препратката към модела на паметта, imho друго задължително четиво е Паралелното програмиране на Дъг Лий в Java: Принципи и модели на проектиране. - person MilesHampson; 15.08.2012
comment
МайлсХемпсън - Добра гледна точка за възможността просто да преместите synchronized на ниво метод. Честно казано дори не се сетих за това, защото винаги съм го избягвал. Определено ще работи тук, стига нищо друго да не се синхронизира на this. Дъг Леа е умен човек — сигурен съм, че книгата му си заслужава да бъде прочетена. - person DaoWen; 15.08.2012

Според съответния урок за Java,

Вече видяхме, че израз за нарастване, като c++, не описва атомарно действие. Дори много прости изрази могат да дефинират сложни действия, които могат да се разложат на други действия. Има обаче действия, които можете да посочите, които са атомарни:

  • Четенията и записите са атомарни за референтни променливи и за повечето примитивни променливи (всички типове с изключение на long и double).
  • Четенията и записите са атомарни за всички променливи, декларирани volatile (включително long и double променливи).

Това е потвърдено отново от Раздел §17.7 от спецификацията на езика Java

Записите и четенията на препратки винаги са атомарни, независимо дали са реализирани като 32-битови или 64-битови стойности.

Изглежда, че наистина можете да разчитате, че референтният достъп е атомарен; признайте обаче, че това не гарантира, че всички четци ще прочетат актуализирана стойност за global след този запис -- т.е. тук няма гаранция за подреждане на паметта.

Ако използвате имплицитно заключване чрез synchronized за целия достъп до global, тогава можете да създадете известна последователност на паметта тук... но може би е по-добре да използвате алтернативен подход.

Освен това изглежда искате колекцията в global да остане неизменна... за щастие има Collections.unmodifiableSet, който можете да използвате, за да наложите това. Като пример, вероятно трябва да направите нещо като следното...

private volatile Collection global = Collections.unmodifiableSet(new HashSet());

... това, или използвайки AtomicReference,

private AtomicReference<Collection> global = new AtomicReference<>(Collections.unmodifiableSet(new HashSet()));

След това ще използвате Collections.unmodifiableSet и за вашите модифицирани копия.


// ... All reads are done through a reference copy like:
// Collection copy = global;
// for (Object elm: copy) {...
// so the global reference being updated half way through should have no impact

Трябва да знаете, че създаването на копие тук е излишно, тъй като вътрешно for (Object elm : global) създава Iterator както следва...

final Iterator it = global.iterator();
while (it.hasNext()) {
  Object elm = it.next();
}

Следователно няма шанс да преминете към напълно различна стойност за global по време на четене.


Всичко това настрана, съгласен съм с чувство, изразено от DaoWen... има ли някаква причина да въвеждате своя собствена структура от данни тук, когато може да има алтернатива, налична в java.util.concurrent? Реших, че може би имате работа с по-стара Java, тъй като използвате необработени типове, но няма да навреди да попитате.

Можете да намерите семантиката на колекцията за копиране при запис, предоставена от CopyOnWriteArrayList или негов братовчед CopyOnWriteArraySet (което имплементира Set, използвайки първото).


Също предложено от DaoWen, обмисляли ли сте да използвате ConcurrentHashMap? Те гарантират, че използването на for цикъл, както сте направили във вашия пример, ще бъде последователно.

По подобен начин итераторите и изброяванията връщат елементи, отразяващи състоянието на хеш-таблицата в някакъв момент при или след създаването на итератора/изброяването.

Вътрешно, Iterator се използва за подобрено for над Iterable.

Можете да създадете Set от това, като използвате Collections.newSetFromMap като следното:

final Set<E> safeSet = Collections.newSetFromMap(new ConcurrentHashMap<E, Boolean>());
...
/* guaranteed to reflect the state of the set at read-time */
for (final E elem : safeSet) {
  ...
}
person obataku    schedule 15.08.2012
comment
Благодаря за отговора, но има няколко неща, които не са ми ясни. Защо казвате, че подреждането на паметта е проблем? По дадената дефиниция или читателите получават старата структура от данни, или новата структура от данни, нали? Освен това защо бих искал да направя global volatile, като се има предвид, че моят читател може да направи копие на препратката в атомарна операция? Имам чувството, че пропускам нещо тук... - person MilesHampson; 15.08.2012
comment
Предложих ви да използвате volatile, за да гарантирате, че други теми ще получат най-новата написана стойност при четене на полето... в противен случай няма гаранции кога вашите читатели ще получат актуализираната препратка. - person obataku; 15.08.2012
comment
@MilesHampson Оттогава актуализирах публикацията си. Вие сте свободни да задавате допълнителни въпроси, които може да имате. - person obataku; 15.08.2012
comment
Всъщност за мен няма значение кога читателите получават актуализациите (известни последни думи...), така че все още съм на мнение, че решението ми по-горе ще работи. Благодаря за вашите подробни редакции, едно нещо, което трябва да отбележа е, че не вярвам, че е правилно да кажа, че искам неизменна колекция (моята публикация използва remove() например). - person MilesHampson; 15.08.2012
comment
Вие искате неизменни колекции, изложени чрез global; всичките ви модификации са направени върху копието. Имах предвид global = Collections.unmodifiableSet(copy); - person obataku; 15.08.2012
comment
... но да, вашият подход трябва да работи. Не забравяйте, че между другото изобщо няма гаранция, че читателите винаги четат актуализирани стойности. - person obataku; 15.08.2012

Мисля, че първоначалната ви идея беше правилна и DaoWen се справи добре с премахването на грешките. Освен ако не можете да намерите нещо, което прави всичко вместо вас, по-добре е да разберете тези неща, отколкото да се надявате, че някой магически клас ще го направи вместо вас. Магическите класове могат да улеснят живота ви и да намалят броя на грешките, но вие искате да разберете какво правят.

ConcurrentSkipListSet може да ви свърши по-добра работа тук. Може да се отърве от всичките ви многопоточни проблеми.

Той обаче е по-бавен от HashSet (обикновено - HashSets и SkipLists/Trees са трудни за сравнение). Ако правите много четения за всяко писане, това, което имате, ще бъде по-бързо. По-важното е, че ако актуализирате повече от един запис наведнъж, вашите четения могат да видят противоречиви резултати. Ако очаквате, че всеки път, когато има запис A, има запис B и обратно, списъкът за пропускане може да ви даде едно без друго.

С вашето текущо решение, за читателите, съдържанието на картата винаги е вътрешно съгласувано. Четенето може да бъде сигурно, че има A за всяко B. Може да е сигурно, че методът size() дава точния брой елементи, които ще бъдат върнати от итератора. Две итерации ще върнат едни и същи елементи в същия ред.

С други думи, allUpdatesGoThroughHere и ConcurrentSkipListSet са две добри решения на два различни проблема.

person RalphChapin    schedule 15.08.2012
comment
Бях смътно наясно с CSL, но не и с тяхното представяне, ще прочета research.ibm.com/people/m/michael/spaa-2002.pdf, за да видя какво мога да извлека от тях. Целта на въпроса беше да получа най-доброто „стандартно“ решение за моя конкретен въпрос, но мисля, че една от забавните части на SO е даването и получаването на насоки за „как“, вместо просто да предлага „какво“, което е защо продължих да питам отговорите на първоначалната магическа структура на данните за повече подробности. Радвам се за отговора ви, дори ако не мога да го задам като приет. - person MilesHampson; 16.08.2012
comment
@MilesHampson Моят отговор е просто празно бърборене в сравнение с цялата друга упорита работа, свършена тук, но реших, че си струва да се добави. Моето собствено тестване показва, че CSL са с 20 до 30% по-бавни от TreeMaps, може би защото са списъци за прескачане вместо дървета и най-вече поради фантастичния им достъп до паметта. TreeMaps и HashMaps са трудни за сравняване, защото HM често трябва да разпределят нови масиви и могат да объркат GC, когато станат големи, но са много по-бързи от TM, когато не правят тези неща. - person RalphChapin; 16.08.2012

Можете ли да използвате метода Collections.synchronizedSet? От HashSet Javadoc http://docs.oracle.com/javase/6/docs/api/java/util/HashSet.html

Set s = Collections.synchronizedSet(new HashSet(...));
person km1    schedule 15.08.2012
comment
Това важи за достъп до самата колекция, а не за справката, съхранена в полето. - person obataku; 15.08.2012
comment
@veer, от документа на Java се казва public static <T> Set<T> synchronizedSet(Set<T> s) Returns a synchronized (thread-safe) set backed by the specified set. In order to guarantee serial access, it is critical that all access to the backing set is accomplished through the returned set. - person km1; 16.08.2012
comment
да, което не е това, което ОП иска. Между другото не съм гласувал против теб. - person obataku; 16.08.2012

Заменете synchronized, като направите global volatile и ще се оправите, що се отнася до копирането при запис.

Въпреки че присвояването е атомарно, в други нишки то не е подредено с записите в посочения обект. Трябва да има връзка случва се преди, която получавате с volatile или синхронизиране на и двете четене и запис.

Проблемът с множеството актуализации, които се случват наведнъж, е отделен - използвайте една нишка или каквото искате да правите там.

Ако сте използвали synchronized както за четене, така и за запис, тогава би било правилно, но производителността може да не е добра при четения, които трябва да се прехвърлят. ReadWriteLock може да е подходящо, но все пак ще имате записи, блокиращи четения.

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

Разбира се, налични са и едновременни колекции.

person Tom Hawtin - tackline    schedule 15.08.2012
comment
Определено сте прав за необходимостта от ключовата дума volatile, но не мисля, че е безопасно да премахнете synchronized. Без заключване ще имате състояние на състезание между мястото, където copy е създадено и присвоено на global, което вероятно ще доведе до загуба на актуализации. - person DaoWen; 15.08.2012
comment
@DaoWen О, вероятно в зависимост от това, което правите. Що се отнася до актуализирането, като се уверите, че видимото състояние е добро, не е необходимо. Ако това наистина е проблем с производителността, както се предлага, ReadWriteLock би било по-подходящо. - person Tom Hawtin - tackline; 15.08.2012
comment
В дадения пример определено ще има условие за състезание. Бих се съгласил с блокировката за четене и писане, но от друга страна, те обикновено също имат проблема с възможното гладуване на писателите от безкрайни потоци от читатели. Така че, както казахте, всичко зависи. - person DaoWen; 15.08.2012
comment
Синхронизираното не може да бъде премахнато, тъй като имам нужда от случващо се преди, за да гарантирам, че операциите ми по модифициране на структурата на данните са завършени, преди да изпълня заданието. Какво е предимството да направиш глобалната волатилна? Операцията за ангажиране на глобалното състояние и операцията за копиране на препратката към глобалното състояние преди четене вече са атомни, нали? Не е необходимо да налагам ред между двете и тъй като има две различни области на паметта, бих си помислил, че всички читатели могат да продължат да работят в своята област, без да забелязват факта, че глобалната референция се разменя? - person MilesHampson; 15.08.2012
comment
P.S. Не гласувах против вас, мисля, че включването на volatile и ReadWriteLock в дискусията беше ценно, дори ако премахването на synchronized нямаше да работи. - person MilesHampson; 15.08.2012