Як уникнути порушення принципу DRY, коли вам доведеться мати асинхронну та синхронізовану версії коду?


15

Я працюю над проектом, який повинен підтримувати версію асинхронізації та синхронізації тієї ж логіки / методу. Так, наприклад, мені потрібно мати:

public class Foo
{
   public bool IsIt()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return conn.Query<bool>("SELECT IsIt FROM SomeTable");
      }
   }

   public async Task<bool> IsItAsync()
   {
      using (var conn = new SqlConnection(DB.ConnString))
      {
         return await conn.QueryAsync<bool>("SELECT IsIt FROM SomeTable");
      }
   }
}

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


1
Чи можете ви сказати трохи більше про те, що ви тут робите? Зокрема, як ви досягаєте асинхронності у своєму асинхронному методі? Чи пов'язаний CPU з високою затримкою або пов'язаний IO?
Ерік Ліпперт

"один - це асинхронізація, а інший - ні" - це різниця в коді фактичного методу чи просто інтерфейсі? (Очевидно, що тільки інтерфейс можна return Task.FromResult(IsIt());)
Олексій Левенков

Крім того, імовірно, що синхронні та асинхронні версії мають високу затримку. За яких обставин абонент буде використовувати синхронну версію, і як цей абонент буде пом’якшувати той факт, що виклик може зайняти мільярди наносекунд? Чи не викликає абонент синхронної версії те, що вони висять користувальницький інтерфейс? Чи немає інтерфейсу? Розкажіть більше про сценарій.
Ерік Ліпперт

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

@AlexeiLevenkov Різниця справді в коді, я додав якийсь фіктивний код, щоб демонструвати це.
Марко

Відповіді:


15

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

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

Дозвольте мені зламати, чому це так. Почнемо з цього питання:


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

Суть цього питання полягає в тому, "чи можу я поділити синхронний та асинхронний контури, зробивши синхронний шлях просто зробити синхронним очікування на асинхронній версії?"

Дозвольте мені бути зрозумілим щодо цього, оскільки це важливо:

ВИ МОЖЕТЕ НЕБЕЗПЕЧНО ЗАСТОСУВАТИ ВСІ РОБОТИ ВІД СУМОВІВ ЦІХ ЛЮДЕЙ .

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

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

  • Замовте нове лезо з веб-сайту. Це асинхронна операція з високою затримкою.
  • Синхронно чекати - тобто спати, поки у вас не буде лезо в руці .
  • Періодично перевіряйте поштову скриньку, щоб побачити, чи прибув лезо.
  • Вийміть лезо з коробки. Тепер у вас це є в руці.
  • Встановіть лезо в косарку.
  • Косити газон.

Що сталося? Ви вічно спите, тому що операція перевірки пошти тепер відстежується тим, що відбувається після надходження пошти .

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

Якщо ви робите асинхронне зачекання, тоді все добре! Ви періодично перевіряєте пошту, і поки ви чекаєте, ви робите бутерброд або робите податки чи що завгодно; ви продовжуєте виконувати роботу, поки ви чекаєте.

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

Для подальшого читання з цієї теми див

https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html

Стівен пояснює сценарій у реальному світі набагато краще, ніж я можу.


Тепер розглянемо "інший напрямок". Чи можемо ми поділитися кодом, зробивши асинхронну версію просто зробити синхронну версію на робочій нитці?

Це, можливо, і, мабуть, погана ідея з наступних причин.

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

  • Синхронна операція може бути записана не як безпечна для потоків.

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

Подальше читання; знову ж таки, Стівен пояснює це дуже чітко:

Чому б не використовувати Task.Run:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-using.html

Більше сценаріїв "робити і не робити" для Task.Run:

https://blog.stephencleary.com/2013/11/taskrun-etiquette-examples-dont-use.html


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


Чи можете ви пояснити, чому повернення Task.Run(() => SynchronousMethod())у версії async - це не те, чого хоче ОП?
Гійом Сасді

2
@GuillaumeSasdy: Оригінальний плакат відповів на питання, що це за робота: він пов'язаний з IO. Марно виконувати завдання, пов'язані з IO, на робочій нитці!
Ерік Ліпперт

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

2
@Marko майже будь-яке вирішення, яке блокує потік, з часом (при великому навантаженні) буде споживати всі потоки ниток (які зазвичай використовуються для завершення операцій з асинхронізацією). В результаті всі потоки будуть чекати, і жодна не буде доступна, щоб дозволити операціям виконувати частину коду "Асинхронна операція завершена". Більшість обхідних шляхів добре в сценаріях з низьким навантаженням (оскільки є безліч потоків, навіть 2-3 блокуються в рамках кожної окремої операції)… І якщо ви можете гарантувати, що завершення операції з асинхронізацією працює на новій потоці ОС (не пул потоків), це може працювати навіть у всіх випадках (ви так високо платите)
Олексій Левенков

2
Іноді насправді немає іншого способу, як "просто зробити синхронну версію на робочій нитці". Наприклад, саме так Dns.GetHostEntryAsyncреалізовано в .NET і FileStream.ReadAsyncдля певних типів файлів. ОС просто не забезпечує інтерфейс асинхронізації, тому час виконання повинен підробляти його (і це не залежить від мови - скажімо, під час виконання Erlang виконується все дерево робочих процесів , з декількома потоками всередині кожного, щоб забезпечити незаблокуючі введення / виведення диска та ім'я диска дозвіл).
Joker_vD

6

На це важко дати відповідь на один розмір. На жаль, не існує простого, ідеального способу повторного використання між асинхронним та синхронним кодом. Але ось кілька принципів, які слід врахувати:

  1. Асинхронний та синхронний код часто принципово відрізняються. Асинхронний код, як правило, повинен містити маркер скасування, наприклад. І часто це закінчується викликом різних методів (як ваш приклад викликає Query()в одному і QueryAsync()в іншому) або налаштування з'єднань з різними налаштуваннями. Тож навіть коли це структурно схоже, часто буває достатньо відмінностей у поведінці, щоб заслужити їх, якщо вони трактуються як окремий код з різними вимогами. Зверніть увагу на відмінності між реалізаціями методів Async та Sync у класі File, наприклад: не докладається зусиль, щоб змусити їх використовувати один і той же код
  2. Якщо ви надаєте підпис асинхронного методу задля впровадження інтерфейсу, але у вас трапляється синхронна реалізація (тобто, по суті, нічого асинхронного щодо того, що робить ваш метод), ви можете просто повернутися Task.FromResult(...).
  3. Будь-які шматки синхронних логіки , які представляють собою те ж саме між цими двома методами можуть бути вилучені в окремий допоміжний метод і позикових коштів в обох методах.

Удачі.


-2

Легко; мають синхронний виклик асинхронного. Навіть зручний метод Task<T>зробити саме це:

public class Foo
{
   public bool IsIt()
   {
      var task = IsItAsync(); //no await here; we want the Task

      //Some tasks end up scheduled to run before you get them;
      //don't try to run them a second time
      if((int)task.Status > (int)TaskStatus.Created)
          //this call will block the current thread,
          //and unlike Run()/Wait() will prefer the current 
          //thread's TaskScheduler instead of a new thread.
          task.RunSynchronously(); 

      //if IsItAsync() can throw exceptions,
      //you still need a Wait() call to bring those back from the Task
      try{ 
          task.Wait();
          return task.Result;
      }
      catch(Exception ex) 
      { 
          //Handle IsItAsync() exceptions here;
          //remember to return something if you don't rethrow              
      }
   }

   public async Task<bool> IsItAsync()
   {
      // Some async logic
   }
}

1
Дивіться мою відповідь, чому це найгірша практика, яку ви ніколи не повинні робити.
Ерік Ліпперт

1
Ваша відповідь стосується GetAwaiter (). GetResult (). Я цього уникав. Реальність полягає в тому, що іноді вам доведеться зробити метод запустити синхронно, щоб використовувати його в стеку викликів, який не може бути асинхронний. Якщо синхронізації чекання ніколи не слід робити, то, як (колишній) член команди C #, будь ласка, скажіть мені, чому ви додаєте не один, а два способи синхронного очікування асинхронного завдання в TPL.
КітС

Особою, яка задала це питання, був би Стівен Туб, а не я. Я працював лише з мовної сторони речей.
Ерік Ліпперт
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.