Оптимізація відключення "в той час (1);" в C ++ 0x


153

Оновлено, дивіться нижче!

Я чув і читав, що C ++ 0x дозволяє компілятору надрукувати "Привіт" для наступного фрагмента

#include <iostream>

int main() {
  while(1) 
    ;
  std::cout << "Hello" << std::endl;
}

Це, мабуть, має щось спільне з потоками та можливостями оптимізації. Мені здається, що це може здивувати багатьох людей.

Хтось має добре пояснення, чому це потрібно було дозволити? Для довідки, останній C ++ 0x чернетка говорить на6.5/5

Цикл, який за межами for-init-заяви у випадку заяви для заяви,

  • не здійснює дзвінків до функцій вводу / виводу бібліотеки та
  • не отримує доступу та не змінює летючі об'єкти;
  • не виконує жодних операцій синхронізації (1.10) або атомних операцій (п. 29)

може припускати, що реалізація припиняється. [Примітка: Це призначено для того, щоб дозволити трансформації компілятора, такі як видалення порожніх циклів, навіть коли припинення неможливо довести. - кінцева примітка]

Редагувати:

Ця прониклива стаття говорить про цей текст Стандартів

На жаль, слова "невизначена поведінка" не вживаються. Однак у будь-який час, коли стандарт каже, що "компілятор може вважати P", мається на увазі, що програма, яка має властивість not-P, має невизначену семантику.

Це правильно, і чи дозволяється компілятору надрукувати "Bye" для вищевказаної програми?


Тут є ще більш проникливий потік , який стосується аналогічної зміни на C, розпочатої хлопцем, виконаною вище зв'язаною статтею. Серед інших корисних фактів вони представляють рішення, яке, здається, також стосується C ++ 0x ( оновлення : з n3225 це більше не працюватиме - див. Нижче!)

endless:
  goto endless;

Здається, компілятору не дозволяється оптимізувати це, бо це не цикл, а стрибок. Інший хлопець підсумовує запропоновані зміни у C ++ 0x та C201X

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


Оновлення 3.1.2011 з n3225: Комітет перемістив текст до 1.10 / 24 і сказав

Реалізація може припускати, що з часом будь-який потік виконує одне з наступних дій:

  • припинити,
  • здійснити дзвінок у функцію вводу / виводу бібліотеки,
  • отримати доступ або змінити летючий об'єкт, або
  • виконати операцію синхронізації або атомну операцію.

gotoТрюк буде НЕ працювати більше!


4
while(1) { MyMysteriousFunction(); }повинні бути самостійно складені, не знаючи визначення цієї загадкової функції, правда? Тож як ми можемо визначити, чи здійснює він дзвінки до будь-яких функцій вводу / виводу бібліотеки? Іншими словами: напевно, що перша куля могла бути сформульована, не викликає функцій .
Даніель Ервікер

19
@Daniel: Якщо він має доступ до визначення функції, це може довести багато чого. Існує таке поняття, як міжпроцедурна оптимізація.
Potatoswatter

3
Зараз у C ++ 03 чи дозволяється компілятор переходити int x = 1; for(int i = 0; i < 10; ++i) do_something(&i); x++;на for(int i = 0; i < 10; ++i) do_something(&i); int x = 2;? Або, можливо, в інший спосіб, xініціалізуючись до 2циклу. Він може сказати, do_somethingщо не хвилює значення x, тому його ідеально безпечна оптимізація, якщо do_something не призведе iдо зміни значення, яке опиниться в нескінченному циклі.
Денніс Зіккефуз

4
Так це означає, що main() { start_daemon_thread(); while(1) { sleep(1000); } }може просто вийти негайно замість запуску мого демона у фоновому потоці?
Гейб

2
"Ця глибока стаття" передбачає, що конкретна поведінка є невизначеною поведінкою лише тому, що немає явної, визначеної поведінки. Це неправильне припущення. Взагалі, коли стандарт залишає відкритим обмежену кількість поведінки, реалізація повинна вибрати будь-яку з них ( Невизначена поведінка). Це не повинно бути детермінованим. Чи закінчується цикл "нічого робити" - це, мабуть, логічний вибір; або це робить, або ні. Робити щось інше заборонено.
MSalters

Відповіді:


33

Хтось має добре пояснення, чому це потрібно було дозволити?

Так, Ганс Бом надає обґрунтування цього в N1528: Чому невизначена поведінка для нескінченних циклів? , хоча це документ WG14, обгрунтування стосується і C ++, і документ стосується як WG14, так і WG21:

Як правильно зазначає N1509, поточна чернетка по суті надає невизначеної поведінки нескінченним циклам в 6.8.5p6. Основна проблема для цього полягає в тому, що він дозволяє коду переміщатися через потенційно не закінчується цикл. Наприклад, припустимо, що у нас є такі петлі, де count і count2 - глобальні змінні (або їх адреса взята), а p - локальна змінна, адреса якої не взята:

for (p = q; p != 0; p = p -> next) {
    ++count;
}
for (p = q; p != 0; p = p -> next) {
    ++count2;
}

Чи можна об'єднати ці дві петлі та замінити їх наступними циклами?

for (p = q; p != 0; p = p -> next) {
        ++count;
        ++count2;
}

Без спеціального розподілу в 6.8.5p6 для нескінченних циклів це буде заборонено: Якщо перша петля не закінчується, оскільки q вказує на круговий список, оригінал ніколи не пише в count2. Таким чином, він може бути запущений паралельно з іншим потоком, який отримує доступ або оновлює count2. Це вже не є безпечним для перетвореної версії, яка робить доступ до count2, незважаючи на нескінченний цикл. Таким чином, перетворення потенційно вводить перегони даних.

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

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

Очевидно також, що для-циклів із цілою змінною циклу, в яких компілятору буде складно довести припинення, і, таким чином, компілятору буде важко реструктурувати петлі без 6.8.5p6. Навіть щось подібне

for (i = 1; i != 15; i += 2)

або

for (i = 1; i <= 10; i += j)

здається нетривіальним впоратися. (У першому випадку для підтвердження закінчення потрібна деяка основна теорія чисел, в другому випадку нам потрібно знати щось про можливі значення j, щоб це зробити. Обгортання непідписаних цілих чисел може ускладнити дещо з цих міркувань далі. )

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

Основна відмінність C - це те, що C11 забезпечує виняток для керування виразами, які є постійними виразами, що відрізняється від C ++ і робить ваш конкретний приклад добре визначеним у C11.


1
Чи існують якісь безпечні та корисні оптимізації, які сприяють теперішній мові, що не було б полегшеним так само, сказавши "Якщо припинення циклу залежить від стану будь-яких об'єктів, час, необхідний для виконання циклу, не вважається помітний побічний ефект, навіть якщо такий час буває нескінченним ". Даний do { x = slowFunctionWithNoSideEffects(x);} while(x != 23);код підйому після циклу, від якого не залежав xби, здавався б безпечним і розумним, але дозволяти компілятору приймати x==23такий код здається небезпечнішим, ніж корисним.
supercat

47

Для мене відповідним обґрунтуванням є:

Це покликане дозволити трансформації компілятора, такі як видалення порожніх циклів, навіть коли припинення неможливо довести.

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

EDIT: Німий приклад:

while (complicated_condition()) {
    x = complicated_but_externally_invisible_operation(x);
}
complex_io_operation();
cout << "Results:" << endl;
cout << x << endl;

Тут було б швидше зробити один потік, complex_io_operationа інший виконує всі складні обчислення в циклі. Але без наведеного вами застереження компілятор повинен довести дві речі, перш ніж він зможе зробити оптимізацію: 1) що complex_io_operation()не залежить від результатів циклу, і 2), що цикл закінчиться . Довести 1) досить просто, довести 2) - проблема зупинки. За допомогою пункту може бути припущено, що цикл закінчується та отримує виграш паралелізації.

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

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

EDIT: Стосовно проникливої ​​статті, яку ви зараз посилаєте, я б сказав, що "компілятор може вважати X про програму" логічно еквівалентний "якщо програма не задовольняє X, поведінка не визначена". Ми можемо показати це так: припустимо, існує програма, яка не задовольняє властивості X. Де було б визначено поведінку цієї програми? Стандарт визначає лише поведінку, припускаючи, що властивість X є істинною. Хоча Стандарт прямо не оголошує поведінку невизначеною, він оголосив її невизначеною упущенням.

Розглянемо аналогічний аргумент: "компілятор може припустити, що змінна x призначається тільки максимум один раз між точками послідовності", що еквівалентно "присвоєнню x більше одного разу між точками послідовності не визначено".


"Довести 1) досить просто" - насправді чи не випливає відразу з 3 умов, щоб компілятор міг дозволити припинення циклу відповідно до пункту, про який Йоганнес запитує? Я думаю, що вони складають, що "цикл не має спостережуваного ефекту, за винятком, можливо, спінінг назавжди", і застереження гарантує, що "спінінг назавжди" не гарантується поведінкою для таких циклів.
Стів Джессоп

@Steve: легко, якщо цикл не припиняється; але якщо цикл припиняється, він може мати нетривіальну поведінку, що впливає на обробку complex_io_operation.
Філіп Поттер

На жаль, я пропустив, що це може змінити енергонезалежних місцевих жителів / псевдонімів / будь-що, що використовується в IO op. Отже, ви маєте рацію: хоча це не обов'язково випливає, є багато випадків, коли компілятори можуть і довести, що такої модифікації не відбувається.
Стів Джессоп

"Це, однак, означає, що нескінченна кількість циклів, що використовуються в прикладах навчання, постраждає в результаті, і підніме отримані прийоми в початковому коді. Я не можу сказати, що це цілком гарна річ" Просто компілюйте з оптимізаціями вимкнено, і воно все одно має працювати
KitsuneYMG

1
@supercat: Те, що ви описуєте, - це буде на практиці, але це не те, що вимагає проект стандарту. Ми не можемо припустити, що компілятор не знає, чи закінчиться цикл. Якщо компілятор робить знає цикл не закінчиться, він може робити все , що завгодно. DS9K буде створювати носові демонів для будь-якого нескінченного циклу, без введення / виведення і т.д. (Тому DS9K вирішує проблеми зупинки.)
Philip Поттер

15

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

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

Якщо нескінченними циклами є UB, це просто означає, що незакінчені програми не вважаються значущими: згідно з C ++ 0x, вони не мають семантики.

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


7
"Порожні нескінченні петлі - це невизначена поведінка"? Алан Тьюрінг благав відрізнятися, але лише тоді, коли він перестане крутитися в могилі.
Дональні стипендіати

11
@Donal: Я ніколи нічого не говорив про його семантику в машині Тьюрінга. Ми обговорюємо семантику нескінченного циклу без бічних ефектів у C ++ . І коли я читаю, C ++ 0x вирішує сказати, що такі петлі не визначені.
jalf

Порожні нескінченні петлі були б дурними, і немає ніяких причин мати для них особливі правила. Це правило призначене для роботи з корисними петлями необмеженої (сподіваємось, не безмежною) тривалості, які обчислюють щось, що знадобиться в майбутньому, але не відразу.
supercat

1
Чи означає це, що C ++ 0x не підходить для вбудованих пристроїв? Практично всі вбудовані пристрої не мають кінця і виконують свою роботу всередині великого жиру while(1){...}. Вони навіть звичайно використовують, while(1);щоб викликати скидання за допомогою сторожового собаки.
vsz

1
@vsz: перша форма чудова. Нескінченні петлі чудово визначені, якщо вони мають певну поведінку, що спостерігається. Друга форма складніша, але я можу придумати два дуже прості шляхи: (1) компілятор, орієнтований на вбудовані пристрої, може просто вибрати для цього більш чітку поведінку, або (2) створити тіло, яке викликає якусь функцію фіктивного бібліотеки . Поки компілятор не знає, що робить ця функція, він повинен припустити, що він може мати якийсь побічний ефект, і тому він не може возитися з циклом.
jalf

8

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


3
Приємне запитання. Схоже, у цього хлопця була саме така проблема, що цей параграф дозволяє цьому компілятору викликати. У пов'язаній дискусії за одним із відповідей написано, що "На жаль, слова" невизначена поведінка "не використовуються. Однак, коли б стандарт сказав:" компілятор може вважати P ", мається на увазі, що програма, яка має властивість not-P має невизначену семантику. " . Це мене дивує. Чи означає це, що моя прикладна програма вище має невизначене поведінку, і може просто звідкись поділитися за замовчуванням?
Йоханнес Шауб - ліб

@Johannes: текст "може бути припущений" не зустрічається більше ніде в чернеті, який я маю подати, і "можливо припускати" виникає лише пару разів. Хоча я перевірив це за допомогою функції пошуку, яка не відповідає рівню перерв рядків, тому, можливо, я пропустила їх. Тож я не впевнений, що авторське узагальнення є обґрунтованим на свідченнях, але як математик я повинен визнати логіку аргументу, що якщо компілятор вважає щось помилковим, то в цілому він може вивести що завгодно ...
Стів Джессоп

... Дозвіл суперечності міркувань компілятора щодо програми, безумовно, дуже сильно натякає на UB, оскільки, зокрема, це дозволяє компілятору для будь-якого X вивести, що програма еквівалентна X. Безумовно, дозволяє компілятору вивести, що є дозволяючи це робити . Я також погоджуюся з автором, що якщо призначений UB, це має бути чітко зазначено, а якщо він не призначений, то текст специфікації є неправильним і повинен бути виправлений (можливо, еквівалентним мовою специфіки ", компілятор може замінити цикл з кодом, який не має ефекту ", я не впевнений).
Стів Джессоп

@SteveJessop: Що ви думаєте, просто сказати, що виконання будь-якого фрагмента коду, включаючи нескінченні цикли, може бути відкладено до тих пір, поки щось, що зробить фрагмент коду, вплине на поведінку програми, що спостерігається, і що для цього потрібно правило, час, необхідний для виконання фрагмента коду - навіть якщо він нескінченний - не є "спостережуваним побічним ефектом". Якщо компілятор може продемонструвати, що цикл не може вийти без змінної, яка містить певне значення, вважається, що змінна може містити це значення, навіть може бути показано, що цикл не може вийти з ним, утримуючи це значення.
supercat

@supercat: як ви вже заявляли про цей висновок, я не думаю, що він покращує ситуацію. Якщо цикл, по суті, ніколи не виходить, тоді для будь-якого об'єкта Xі біт-шаблона xкомпілятор може продемонструвати, що цикл не виходить, не Xутримуючи біт-шаблон x. Це правдиво правда. Таким чином, Xможна вважати, що він тримає будь-яку біт-схему, і це так само погано, як і UB, в тому сенсі, що для неправильних Xі xце швидко спричинить деякі. Тому я вважаю, що ви повинні бути більш точними у своїх стандартах. Важко говорити про те, що відбувається "в кінці" нескінченного циклу, і показувати це еквівалентно деякій кінцевій операції.
Стів Джессоп

8

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

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

volatile int dummy_side_effect;

while (1) {
    dummy_side_effect = 0;
}

printf("Never prints.\n");

2
@ JohannesSchaub-litb: Якщо цикл - нескінченний чи ні - під час виконання не читає і не записує змінних змінних і не викликає жодних функцій, які могли б зробити це, компілятор вільний відкладати будь-яку частину циклу до перші зусилля, щоб отримати доступ до чогось обчисленого в ньому. Враховуючи unsigned int dummy; while(1){dummy++;} fprintf(stderror,"Hey\r\n"); fprintf(stderror,"Result was %u\r\n",dummy);, перший fprintfміг виконати, а другий не міг (компілятор міг перемістити обчислення dummyміж двома fprintf, але не минулий той, який друкує його значення).
supercat

1

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

недійсний тестфермат (int n)
{
  int a = 1, b = 1, c = 1;
  while (pow (a, n) + pow (b, n)! = pow (c, n))
  {
    якщо (b> a) a ++; інакше, якщо (c> b) {a = 1; b ++}; інакше {a = 1; b = 1; c ++};
  }
  printf ("Результат є");
  printf ("% d /% d /% d", a, b, c);
}

як

недійсний тестфермат (int n)
{
  if (fork_is_first_thread ())
  {
    int a = 1, b = 1, c = 1;
    while (pow (a, n) + pow (b, n)! = pow (c, n))
    {
      якщо (b> a) a ++; інакше, якщо (c> b) {a = 1; b ++}; інакше {a = 1; b = 1; c ++};
    }
    signal_other_thread_and_die ();
  }
  else // Друга нитка
  {
    printf ("Результат є");
    wait_for_other_thread ();
  }
  printf ("% d /% d /% d", a, b, c);
}

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

  int total = 0;
  для (i = 0; num_reps> i; i ++)
  {
    update_progress_bar (i);
    total + = do_something_slow_with_no_side_effects (i);
  }
  show_result (всього);

став би

  int total = 0;
  if (fork_is_first_thread ())
  {
    для (i = 0; num_reps> i; i ++)
      total + = do_something_slow_with_no_side_effects (i);
    signal_other_thread_and_die ();
  }
  ще
  {
    для (i = 0; num_reps> i; i ++)
      update_progress_bar (i);
    wait_for_other_thread ();
  }
  show_result (всього);

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


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

@ Філіп Поттер: Якщо повільна рутина мала побічні ефекти, це, безумовно, було би правдою. У моєму прикладі раніше це було б безглуздо, якби цього не було, тому я змінив його. Моя інтерпретація специфікації полягає в тому, що системі дозволено відкладати виконання повільного коду до тих пір, поки його ефекти (крім часу, необхідного для виконання) не стануть видимими, тобто виклику show_result (). Якщо штрих-код прогресу використовував поточний підсумок або принаймні робив вигляд, що це робиться, це змусить його синхронізуватися з повільним кодом.
supercat

1
Це пояснює всі ті смуги прогресу, які швидко йдуть від 0 до 100, а потім вішають віками;)
паульм

0

Це не вирішується для компілятора для нетривіальних випадків, якщо він зовсім нескінченний цикл.

У різних випадках може статися, що ваш оптимізатор досягне кращого класу складності для вашого коду (наприклад, це був O (n ^ 2), і ви отримаєте O (n) або O (1) після оптимізації).

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


Інша річ: я ніколи не бачив жодного поважного прикладу, коли тобі потрібна нескінченна петля, яка нічого не робить.

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

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


1
Приклад, де вам може бути потрібний нескінченний цикл: вбудована система, де ви не хочете спати з міркувань продуктивності, і весь код висить з перерви або двох?
JCx

@JCx у стандарті C переривання повинні встановлювати прапор, який перевіряє основний цикл, тому головний цикл має спостерігати поведінку у випадку встановлення прапорів. Запуск істотного коду в перериваннях не є портативним.
ММ

-1

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

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


Якщо вони взаємодіють з іншими потоками, ви не повинні робити їх непостійними, робити їх атомами або захищати їх замком.
BCoates

1
Thjs - жахлива порада. Створення їх volatileне є необхідним і не достатнім, і це шкодить продуктивності.
Девід Шварц
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.