Чи повністю виключає незмінність необхідності блокування в багатопроцесорному програмуванні?


39

Частина 1

Очевидно, що незмінність мінімізує потребу в блокуваннях в багатопроцесорному програмуванні, але чи усуває це потреба, чи є випадки, коли однієї незмінюваності недостатньо? Мені здається, ви можете лише відкладати обробку та інкапсулювати стан так довго, перш ніж більшість програм насправді повинні щось зробити (оновити сховище даних, створити звіт, викинути виняток тощо). Чи можна завжди робити такі дії без замків? Чи сама дія викидання кожного об’єкта та створення нового замість зміни оригіналу (груба думка про незмінність) забезпечує абсолютний захист від міжпроцесорних суперечок, чи є кутові випадки, які ще потребують блокування?

Я знаю, що багато функціональних програмістів і математиків люблять говорити про "відсутність побічних ефектів", але в "реальному світі" все має побічний ефект, навіть якщо це час, необхідний для виконання машинної інструкції. Мене цікавлять як теоретична / академічна відповідь, так і практична / реальна відповідь.

Якщо незмінність безпечна, враховуючи певні межі чи припущення, я хочу знати, які саме межі "зони безпеки". Деякі приклади можливих меж:

  • I / O
  • Винятки / помилки
  • Взаємодія з програмами, написаними іншими мовами
  • Взаємодія з іншими машинами (фізичними, віртуальними або теоретичними)

Особлива подяка @JimmaHoffa за коментар, який розпочав це питання!

Частина 2

Багатопроцесорне програмування часто використовується як техніка оптимізації - щоб швидше запустити деякий код. Коли швидше використовувати блокування проти незмінних об'єктів?

Враховуючи обмеження, встановлені Законом Амдала , коли можна досягти кращої загальної продуктивності (з урахуванням сміттєзбірника або без нього) з незмінними предметами проти блокування змінних?

Підсумок

Я поєдную ці два питання в одне, щоб спробувати дістатись до того, де обмежується поле для незмінюваності як рішення проблем з ниткою.


21
but everything has a side effect- Ага, ні, ні. Функція, яка приймає якесь значення і повертає якесь інше значення, і нічого не порушує поза функцією, не має побічних ефектів, і тому є безпечною для потоків. Не має значення, що комп'ютер використовує електрику. Ми можемо говорити і про космічні промені, що вражають клітини пам'яті, якщо вам це подобається, але давайте збережемо аргумент на практиці. Якщо ви хочете розглянути такі речі, як те, як спосіб виконання функції впливає на споживання енергії, це інша проблема, ніж безпечне програмування потоків.
Роберт Харві

5
@RobertHarvey - Можливо, я просто використовую інше визначення побічного ефекту, і я мав би сказати, «побічний ефект у реальному світі». Так, математики мають функції без побічних ефектів. Код, який виконується на машині реального світу, вимагає виконання машинних ресурсів, незалежно від того, чи він мутує дані чи ні. Функція у вашому прикладі ставить своє повернене значення на стек у більшості машинних архітектур.
GlenPeterson

1
Якщо ви насправді зможете пройти через це, я думаю, що ваше питання стосується основи цього сумнозвісного паперового дослідження.microsoft.com
en-us/um/people/simonpj/papers/…

6
Для нашої дискусії я припускаю, що ви посилаєтесь на машину, повну Тьюрінга, яка виконує якусь чітко визначену мову програмування, де деталі реалізації не мають значення. Іншими словами, не має значення, що робить стек, якщо функція, яку я пишу мовою програмування на вибір, може гарантувати незмінність у межах цієї мови. Я не думаю про стек, коли програмую на мові високого рівня, і не повинен.
Роберт Харві

1
@ Споронізм @RobertHarvey; Монади хе, І ви можете зібрати це з перших сторінок. Я згадую це, тому що в цілому він детально описує техніку поводження з побічними ефектами практично чистим способом, я впевнений, що він відповість на питання Глена, тому опублікував це як гарну ноту для всіх, хто знайде це питання в майбутнє для подальшого читання.
Джиммі Хоффа

Відповіді:


35

Це дивно сформульоване запитання, яке є дійсно, дуже широким, якщо відповісти повною мірою. Я збираюся зосередитись на з'ясуванні деяких особливостей, про які ви питаєте.

Незмінність - це компромісний дизайн. Це ускладнює деякі операції (швидка зміна стану великих об’єктів, побудова об'єктів на частинах, збереження запущеного стану тощо) на користь інших (легше налагодження, простіші міркування про поведінку програми, не турбуватися про те, що зміни під вами змінюються під час роботи одночасно тощо). Це останнє, що нас хвилює в цьому питанні, але я хочу підкреслити, що це інструмент. Хороший інструмент, який часто вирішує більше проблем, ніж це викликає (у більшості сучасних програм), але не срібною кулею ... Не те, що змінює внутрішню поведінку програм.

Тепер, що це отримує від вас? Незмінюваність отримує одне: ви можете читати незмінний об'єкт вільно, не турбуючись про те, що його стан змінюється під вами (якщо припустити, що це справді глибоко незмінне ... Наявність непорушного об'єкта з членами, що змінюються, як правило, є вимикачем угоди). Це воно. Це звільняє вас від необхідності керувати паралельністю (за допомогою замків, знімків, розподілу даних або інших механізмів; орієнтація оригінального питання на блокування - неправильна, враховуючи обсяг питання).

Виявляється, хоча багато речей читають предмети. IO робить, але сам IO, як правило, не справляється з одночасним використанням. Майже вся обробка робить, але інші об'єкти можуть бути зміненими, або сама обробка може використовувати стан, не прихильний до паралельності. Копіювання об'єкта є великою прихованою проблемою в деяких мовах, оскільки повна копія (майже) ніколи не є атомною операцією. Тут допомагають вам незмінні предмети.

Що стосується продуктивності, це залежить від вашої програми. Замки (як правило) важкі. Інші механізми управління сумісністю швидші, але сильно впливають на ваш дизайн. Взагалі , конструкція з високою сумісністю, яка використовує незмінні об'єкти (і дозволяє уникнути їх слабких сторін), буде краще, ніж конструкція з високою сумісністю, яка блокує змінні об'єкти. Якщо ваша програма злегка паралельна, то це залежить і / або не має значення.

Але продуктивність не повинна викликати найбільше значення. Написання одночасних програм важко . Налагодження паралельних програм важко . Незмінні об’єкти допомагають покращити якість вашої програми, усуваючи можливості для керування помилками вручну керування сумісністю. Вони полегшують налагодження, оскільки ви не намагаєтесь відстежувати стан у одночасній програмі. Вони роблять ваш дизайн більш простим і, таким чином, видаляють помилки там.

Отже, підводячи підсумок: незмінність допомагає, але не усуває проблеми, необхідні для правильного поводження з одночасністю. Ця допомога має тенденцію до поширення, але найбільший приріст - це з точки зору якості, а не від продуктивності. І ні, незмінність не магічно виправдає вас від керування одночасністю у вашому додатку, вибачте.


+1 Це має сенс, але чи можете ви навести приклад того, коли на глибоко незмінній мові вам все одно доводиться турбуватися про правильне поводження з одночасністю? Ви заявляєте, що так робите, але такий сценарій мені незрозумілий
Джиммі Хоффа

@JimmyHoffa Незмінною мовою вам все одно потрібно оновити стан між потоками. Дві найнезмінніші мови, які я знаю (Clojure та Haskell), забезпечують тип еталону (атоми та Мвари), які забезпечують спосіб перенесення зміненого стану між потоками. Семантика їх типів перешкод перешкоджає певним помилкам одночасності, але інші все ж можливі.
каменеметал

@stonemetal Цікаво, що за свої 4 місяці з Haskell я навіть не чув про Мварса, я завжди чув, як використовую STM для комунікації стану держав, яка поводиться більше, як повідомлення Ерланга, що я думав. Хоча ідеальний приклад незмінюваності, що не вирішує одночасних проблем, я можу подумати - це оновлення інтерфейсу користувача, якщо у вас є 2 потоки, які намагаються оновити інтерфейс користувача з різними версіями даних, одна може бути новішою і тому потрібно отримати друге оновлення, щоб у вас з'явився умова гонки, де ви повинні якось гарантувати послідовність .. Цікава думка .. Дякую за деталі
Джиммі Хоффа

1
@jimmyhoffa - найпоширеніший приклад - IO. Навіть якщо мова незмінна, ваша база даних / веб-сайт / файл не є. Інша ваша типова карта / зменшення. Незмінюваність означає, що агрегація карти є більш нерозумною, але вам все одно потрібно обробити координацію ", коли вся карта буде зроблена паралельно, зменшіть" координацію.
Теластин

1
@JimmyHoffa: MVars - примітивні паралельні параметри одночасного змінення (технічно незмінна посилання на мінливе місце зберігання), не надто відрізняються від того, що ви бачили б на інших мовах; тупики та умови гонки дуже можливі. STM - це абстракція паралельної конкуренції на високому рівні для безперешкодної змінної спільної пам’яті (сильно відрізняється від передачі повідомлень), що дозволяє здійснювати композиційні транзакції без можливості тупикових ситуацій або перегонів. Незмінні дані просто захищені від потоку, про них нічого більше не сказати.
CA McCann

13

Функція, яка приймає якесь значення і повертає якесь інше значення, і нічого не порушує поза функцією, не має побічних ефектів, і тому є безпечною для потоків. Якщо ви хочете розглянути такі речі, як те, як спосіб виконання функції впливає на споживання енергії, це вже інша проблема.

Я припускаю, що ви маєте на увазі машину, повну Тьюрінга, яка виконує якусь чітко визначену мову програмування, де деталі реалізації не мають значення. Іншими словами, не має значення, що робить стек, якщо функція, яку я пишу мовою програмування на вибір, може гарантувати незмінність у межах цієї мови. Я не думаю про стек, коли програмую на мові високого рівня, і не повинен.

Щоб проілюструвати, як це працює, я запропоную кілька простих прикладів у C #. Для того, щоб ці приклади були правдивими, ми повинні зробити пару припущень. По-перше, компілятор без помилок слідує специфікації C #, а по-друге, він створює правильні програми.

Скажімо, я хочу просту функцію, яка приймає колекцію рядків і повертає рядок, яка є об'єднанням усіх рядків у колекції, розділених комами. Проста, наївна реалізація в C # може виглядати так:

public string ConcatenateWithCommas(ImmutableList<string> list)
{
    string result = string.Empty;
    bool isFirst = false;

    foreach (string s in list)
    {
        if (isFirst)
            result += s;
        else
            result += ", " + s;
    }
    return result;
} 

Цей приклад незмінний, prima facie. Звідки я це знаю? Тому що stringоб’єкт незмінний. Однак реалізація не є ідеальною. Оскільки resultє незмінним, кожен струнний об'єкт повинен створюватися щоразу через цикл, замінюючи початковий об'єкт, на який resultвказує. Це може негативно вплинути на швидкість та чинити тиск на сміттєзбірник, оскільки він повинен очистити всі ці зайві струни.

Тепер, скажімо, я роблю це:

public string ConcatenateWithCommas(ImmutableList<string> list)
{
    var result = new StringBuilder();
    bool isFirst = false;

    foreach (string s in list)
    {
        if (isFirst)
            result.Append(s);
        else
            result.Append(", " + s);
    }
    return result.ToString();
} 

Зверніть увагу , що я замінив string resultіз змінним об'єктом, StringBuilder. Це набагато швидше, ніж перший приклад, оскільки нова струна не створюється кожного разу через цикл. Натомість об'єкт StringBuilder просто додає символи з кожного рядка до колекції символів і в кінці виводить всю справу.

Чи ця функція незмінна, навіть якщо StringBuilder не змінюється?

Так. Чому? Оскільки щоразу, коли ця функція викликається, створюється новий StringBuilder, саме для цього виклику. Тож тепер у нас є чиста функція, яка є безпечною для потоків, але містить змінні компоненти.

Але що робити, якщо я це зробив?

public class Concatenate
{
    private StringBuilder result = new StringBuilder();
    bool isFirst = false;

    public string ConcatenateWithCommas(ImmutableList<string> list)
    {
        foreach (string s in list)
        {
            if (isFirst)
                result.Append(s);
            else
                result.Append(", " + s);
        }
        return result.ToString();
    } 
}

Чи безпечний цей спосіб для потоків? Ні, це не так. Чому? Тому що зараз клас тримає стан, від якого залежить мій метод. У методі зараз присутня умова перегонів: один потік може змінюватись IsFirst, але інший потік може виконувати перший Append(), і в цьому випадку у мене зараз є кома на початку мого рядка, якого, як передбачається, немає.

Чому я можу зробити це так? Ну, я б хотів, щоб нитки накопичували рядки в моєму, resultне зважаючи на порядок, або в тому порядку, в який вони входять. Можливо, це реєстратор, хто знає?

У будь-якому випадку, щоб виправити це, я помістив lockзаяву навколо нутрощів методу.

public class Concatenate
{
    private StringBuilder result = new StringBuilder();
    bool isFirst = false;
    private static object locker = new object();

    public string AppendWithCommas(ImmutableList<string> list)
    {
        lock (locker)
        {
            foreach (string s in list)
            {
                if (isFirst)
                    result.Append(s);
                else
                    result.Append(", " + s);
            }
            return result.ToString();
        }
    } 
}

Тепер знову безпечно для ниток.

Єдиний спосіб, коли мої незмінні методи, можливо, не можуть бути безпечними для потоків - це якщо метод якимось чином просочиться частиною його реалізації. Це могло статися? Не в тому випадку, якщо компілятор правильний і програма правильна. Чи мені колись потрібні будуть замки на таких методах? Ні.

Приклад того, як реалізація могла просочитися в сценарії одночасності, дивіться тут .


2
Якщо я не помиляюся, оскільки a Listє змінним, в першій функції, яку ви заявили про "чисту", інший потік може видалити всі елементи зі списку або додати ще купу, поки він знаходиться в циклі foreach. Невідомо, як це буде грати з IEnumeratorістотою while(iter.MoveNext()), але якщо це не IEnumeratorбуде непорушним (сумнівним), то це загрожує розірвати петлю передбачення.
Джиммі Хоффа

Щоправда, ви повинні припустити, що колекція ніколи не пишеться, поки нитки читають з неї. Це було б справедливим припущенням, якщо кожен потік виклику методу будує свій власний список.
Роберт Харві

Я не думаю, що ви можете назвати це "чистим", коли в ньому є той змінний об'єкт, який він використовує за посиланням. Якщо він отримав IEnumerable, ви, можливо, зможете подати цю претензію, оскільки ви не можете додавати або видаляти елементи з IEnumerable, але тоді це може бути масив або список, переданий як IEnumerable, тому контракт IEnumerable не гарантує жодної форми чистоти. Справжньою технікою зробити цю функцію чистою буде незмінність за допомогою передачі копії, C # не робить цього, тому вам доведеться скопіювати список, коли функція його отримує; але єдиний спосіб зробити це - передбачивши це ...
Джиммі Хоффа

1
@JimmyHoffa: Чорт, ти мене одержимий через цю проблему з куркою та яйцями! Якщо ви бачите рішення де завгодно, будь ласка, повідомте мене про це.
Роберт Харві

1
Щойно я натрапив на цю відповідь зараз, і це одне з найкращих пояснень на тему, на яку я натрапив, приклади є дуже стислими і дійсно дозволяють легко бавитися. Спасибі!
Стівен Бірн

4

Я не впевнений, чи зрозумів Ваші запитання.

ІМХО відповідь - так. Якщо всі ваші об'єкти незмінні, вам не потрібні замки. Але якщо вам потрібно зберегти стан (наприклад, ви реалізуєте базу даних або вам потрібно зібрати результати з декількох потоків), тоді вам потрібно використовувати мутабельність, а отже, і блокувати. Незмінність позбавляє від необхідності блокування, але зазвичай ви не можете дозволити собі цілком непорушні програми.

Відповідь до частини 2 - замки повинні бути завжди повільнішими, ніж відсутність замків.


3
Частина друга запитує: "Яке взаємозв'язок між замками та незмінними конструкціями?" Напевно, воно заслуговує власного питання, якщо воно навіть відповідає.
Роберт Харві

4

Інкапсуляція кути спорідненого стану в єдиному зміненому посиланні на незмінний об'єкт може зробити можливим здійснення багатьох видів зміни стану без заблокування за допомогою шаблону:

do
{
   oldState = someObject.State;
   newState = oldState.WithSomeChanges();
} while (Interlocked.CompareExchange(ref someObject.State, newState, oldState) != oldState;

Якщо дві нитки намагаються оновити someObject.stateодночасно, обидва об'єкти будуть читати старий стан і визначати, яким буде новий стан без змін один одного. Перший потік для запуску CompareExchange буде зберігати те, що, на його думку, має бути наступним станом. Другий потік виявить, що стан більше не відповідає тому, що було прочитано раніше, і, таким чином, повторно обчислить належний наступний стан системи з набули чинності змін першого потоку.

Ця модель має перевагу в тому, що нитка, яка отримує накладну, не може блокувати хід інших потоків. Має ще одну перевагу, що навіть коли виникають важкі суперечки, якась нитка завжди буде прогресувати. Однак є недоліком те, що за наявності суперечок багато ниток можуть витратити багато часу на роботу, яку вони в кінцевому підсумку відкинуть. Наприклад, якщо 30 ниток на окремих центральних процесорах намагаються одночасно змінити об'єкт, один досягне успіху при першій спробі, один на другій, третій і т.д., так що кожен потік у середньому закінчується, роблячи близько 15 спроб оновити свої дані. Використання блокування "дорадчий" може значно покращити речі: перед тим, як потік спроби оновлення, він повинен перевірити, чи встановлений показник "суперечність". Якщо так, він повинен придбати замок перед оновленням. Якщо потік робить кілька невдалих спроб оновлення, він повинен встановити прапор суперечки. Якщо нитка, яка намагається придбати замок, виявила, що ніхто більше не чекає, він повинен очистити прапор розбіжності. Зауважте, що замок тут не потрібен для "правильності"; код працював би правильно навіть без нього. Мета блокування - мінімізувати кількість часу, витраченого кодом на операції, які, швидше за все, не матимуть успіху.


4

Ви починаєте з

Ясна незмінність мінімізує потребу в блокуваннях в багатопроцесорному програмуванні

Неправильно. Вам потрібно уважно прочитати документацію для кожного класу, який ви використовуєте. Наприклад, const std :: string в C ++ не є безпечним для потоків. Обмежувані об'єкти можуть мати внутрішній стан, який змінюється при доступі до них.

Але ви дивитесь на це з абсолютно неправильної точки зору. Не має значення, чи є предмет незмінним чи ні, важливо, чи ви його змінили. Те, про що ви говорите, - це як сказати, "якщо ви ніколи не складете іспит з водіння, ви ніколи не можете втратити посвідчення водія за керування в нетверезому стані". Щоправда, але швидше пропускає суть.

Тепер у прикладі коду, який хтось написав із функцією під назвою "ConcatenateWithCommas": Якщо введення було вимкнено, а ви використовували замок, що б ви отримали? Якщо хтось інший намагається змінити список, коли ви намагаєтеся об'єднати рядки, блокування може запобігти вам збої. Але ви все ще не знаєте, чи з'єднуєте ви нитки до або після того, як інша нитка змінила їх. Тож ваш результат досить марний. У вас є проблема, яка не пов’язана з блокуванням і не може бути виправлена ​​блокуванням. Але тоді, якщо ви використовуєте незмінні об’єкти, а інша нитка замінює весь об’єкт новим, ви використовуєте старий, а не новий об'єкт, тому ваш результат марний. Ви повинні думати про ці проблеми на фактичному функціональному рівні.


2
const std::string- поганий приклад і трохи червоної оселедця. Рядки C ++ є змінними і constніяк не можуть гарантувати незмінність. Все, що він робить, це сказати, що constможуть бути викликані лише функції. Однак ці функції все ще можуть змінювати внутрішній стан, і вони constможуть бути відкинуті. Нарешті, є те саме питання, що і будь-яка інша мова: тільки те, що моя довідка const, не означає, що ваша посилання теж є. Ні, слід використовувати справді незмінну структуру даних.
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.