จะหลีกเลี่ยงการละเมิดหลักการ DRY ได้อย่างไรเมื่อคุณต้องมีโค้ดทั้ง async และ sync?


15

ฉันกำลังทำงานในโครงการที่ต้องการสนับสนุนทั้ง async และ sync version ของตรรกะ / วิธีการเดียวกัน ตัวอย่างเช่นฉันต้องมี:

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 () ในวิธีการแบบ async และเรียกใช้จากวิธีการซิงค์ของคุณได้หรือไม่ เธรดนั้นปลอดภัยในทุกสถานการณ์หรือไม่ มีวิธีอื่นที่ดีกว่าในการทำเช่นนี้หรือฉันถูกบังคับให้ทำซ้ำตรรกะ?


1
คุณช่วยพูดอะไรอีกเล็กน้อยเกี่ยวกับสิ่งที่คุณทำที่นี่? โดยเฉพาะอย่างยิ่งคุณจะประสบความสำเร็จในวิธีอะซิงโครนัสในวิธีอะซิงโครนัสของคุณได้อย่างไร? CPU ทำงานแบบแฝงสูงหรือถูกผูกไว้กับ IO หรือไม่?
Eric Lippert

"one is async และอีกอันไม่ใช่" คือความแตกต่างในรหัสของวิธีการจริงหรือเพียงแค่ส่วนต่อประสาน? (เห็นได้ชัดว่าเป็นเพียงส่วนต่อประสานที่คุณสามารถทำได้return Task.FromResult(IsIt());)
Alexei Levenkov

นอกจากนี้สมมุติว่าเวอร์ชันซิงโครนัสและอะซิงโครนัสมีทั้งความล่าช้าสูง ผู้โทรจะใช้เวอร์ชันซิงโครนัสในสถานการณ์ใดและผู้โทรรายนั้นจะลดความจริงที่ว่าผู้รับโทรศัพท์อาจใช้เวลาเป็นพันล้านนาโนวินาทีได้อย่างไร ผู้เรียกรุ่นซิงโครนัสไม่สนใจว่าพวกเขากำลังแขวน UI อยู่หรือไม่? ไม่มี UI หรือไม่ บอกเราเพิ่มเติมเกี่ยวกับสถานการณ์
Eric Lippert

@EricLippert ฉันเพิ่มตัวอย่างดัมมี่เฉพาะเพื่อให้คุณเข้าใจว่า 2 ฐานรหัสนั้นเหมือนกันนอกเหนือจากข้อเท็จจริงที่ว่าหนึ่งคือ async คุณสามารถจินตนาการถึงสถานการณ์ที่วิธีนี้ซับซ้อนและมีเส้นและบรรทัดของรหัสซ้ำกันมากขึ้น
Marko

@AlexeiLevenkov ความแตกต่างแน่นอนในรหัสฉันเพิ่มรหัสจำลองเพื่อสาธิต
Marko

คำตอบ:


15

คุณถามคำถามหลายข้อในคำถามของคุณ ฉันจะทำลายมันให้แตกต่างจากที่คุณทำเล็กน้อย แต่ก่อนอื่นให้ฉันตอบคำถามโดยตรง

เราทุกคนต้องการกล้องที่มีน้ำหนักเบาคุณภาพสูงและราคาถูก แต่เหมือนคำพูดที่ว่าคุณจะทำได้แค่สองในสามส่วนนั้นเท่านั้น คุณอยู่ในสถานการณ์เดียวกันที่นี่ คุณต้องการโซลูชันที่มีประสิทธิภาพปลอดภัยและใช้รหัสร่วมกันระหว่างเส้นทางแบบซิงโครนัสและแบบอะซิงโครนัส คุณจะได้แค่สองอันเท่านั้น

ขอผมแยกเหตุผลว่าทำไม เราจะเริ่มด้วยคำถามนี้:


ฉันเห็นว่ามีคนบอกว่าคุณสามารถใช้GetAwaiter().GetResult()กับวิธีการซิงค์และเรียกใช้จากวิธีการซิงค์ของคุณหรือไม่ เธรดนั้นปลอดภัยในทุกสถานการณ์หรือไม่

ประเด็นของคำถามนี้คือ "ฉันสามารถแชร์เส้นทางแบบซิงโครนัสและแบบอะซิงโครนัสได้หรือไม่โดยการทำให้เส้นทางแบบซิงโครนัสทำได้โดยง่ายเพียงแค่รอแบบซิงโครนัสกับเวอร์ชันอะซิงโครนัส

ให้ฉันชัดเจนสุดในจุดนี้เพราะมันเป็นสิ่งสำคัญ:

คุณควรหยุดทันทีโดยใช้คำแนะนำจากคนเหล่านั้น

นั่นเป็นคำแนะนำที่แย่มาก มันเป็นสิ่งที่อันตรายมากที่จะดึงข้อมูลพร้อมผลจากงานที่ไม่ตรงกันถ้าคุณมีหลักฐานว่างานได้เสร็จสิ้นการได้ตามปกติหรือผิดปกติ

เหตุผลนี้เป็นคำแนะนำที่ไม่ดีอย่างยิ่งคือให้พิจารณาสถานการณ์นี้ คุณต้องการที่จะตัดหญ้าสนามหญ้า แต่ใบมีดเครื่องตัดหญ้าของคุณเสีย คุณตัดสินใจที่จะติดตามเวิร์กโฟลว์นี้:

  • สั่งเบลดใหม่จากเว็บไซต์ นี่คือการดำเนินการแบบอะซิงโครนัสเวลาแฝงสูง
  • พร้อมรอ - นั่นคือการนอนหลับจนกว่าคุณจะมีใบมีดในมือ
  • ตรวจสอบกล่องจดหมายเป็นระยะเพื่อดูว่าใบมีดมาถึงหรือไม่
  • นำใบมีดออกจากกล่อง ตอนนี้คุณมีมันอยู่ในมือ
  • ติดตั้งใบมีดในเครื่องตัดหญ้า
  • ต้ดหญ้า.

เกิดอะไรขึ้น? คุณนอนหลับตลอดไปเพราะการดำเนินงานของการตรวจสอบจดหมายอยู่ในขณะนี้รั้วรอบขอบชิดในสิ่งที่เกิดขึ้นหลังจากจดหมายมาถึง

เป็นเรื่องง่ายมากที่จะเข้าสู่สถานการณ์นี้เมื่อคุณรองานโดยพลการ งานนั้นอาจมีการกำหนดเวลาทำงานในอนาคตของเธรดที่กำลังรออยู่และตอนนี้อนาคตนั้นจะไม่มาถึงเพราะคุณกำลังรองานอยู่

หากคุณรอแบบอะซิงโครนัสทุกอย่างก็โอเค! คุณตรวจสอบจดหมายเป็นระยะและในขณะที่คุณกำลังรอคุณทำแซนด์วิชหรือทำภาษีหรืออะไรก็ตาม คุณทำงานต่อไปเรื่อย ๆ ขณะที่รอ

อย่ารอพร้อมกัน ถ้างานจะทำก็คือไม่จำเป็น หากงานไม่เสร็จ แต่กำหนดให้รันเธรดปัจจุบันจะไม่มีประสิทธิภาพเนื่องจากเธรดปัจจุบันอาจให้บริการงานอื่นแทนการรอ หากงานไม่เสร็จและกำหนดเวลาให้รันเธรดปัจจุบันงานจะค้างเพื่อรอพร้อมกัน ไม่มีเหตุผลที่ดีที่พร้อมรออีกครั้งจนกว่าคุณจะรู้อยู่แล้วว่างานจะเสร็จสมบูรณ์

สำหรับการอ่านเพิ่มเติมในหัวข้อนี้ดู

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

สตีเฟ่นอธิบายสถานการณ์โลกแห่งความเป็นจริงได้ดีกว่าที่ฉันสามารถทำได้


ตอนนี้ลองพิจารณา "ทิศทางอื่น" เราสามารถแชร์รหัสโดยการทำให้รุ่นอะซิงโครนัสสามารถทำเวอร์ชันซิงโครนัสกับเธรดผู้ปฏิบัติงานได้หรือไม่?

นั่นคืออาจจะเป็นและแน่นอนอาจจะเป็นความคิดที่ไม่ดีด้วยเหตุผลดังต่อไปนี้

  • จะไม่มีประสิทธิภาพหากการดำเนินการซิงโครนัสเป็น IO latency สูง สิ่งนี้จ้างคนงานเป็นหลักและทำให้คนงานนั้นหลับจนกว่างานจะเสร็จ หัวข้อที่มีราคาแพงเมามัน พวกเขาใช้พื้นที่ที่อยู่ขั้นต่ำเป็นล้านไบต์โดยค่าเริ่มต้นใช้เวลาพวกเขาใช้ทรัพยากรระบบปฏิบัติการ คุณไม่ต้องการที่จะเผาไหม้ด้ายทำงานไร้ประโยชน์

  • การดำเนินการแบบซิงโครนัสอาจไม่สามารถเขียนเป็นเธรดที่ปลอดภัยได้

  • นี่เป็นเทคนิคที่เหมาะสมกว่าหากงานที่มีความหน่วงสูงมีการเชื่อมโยงกับโปรเซสเซอร์ แต่ถ้าเป็นเช่นนั้นคุณอาจไม่ต้องการส่งมันไปยังเธรดผู้ปฏิบัติงาน คุณอาจต้องการที่จะใช้งานห้องสมุดขนานไปคู่ขนานมันเป็นซีพียูมากที่สุดเท่าที่เป็นไปได้คุณอาจต้องการตรรกะการยกเลิกและคุณไม่สามารถเพียงแค่ทำให้รุ่นซิงโครทำได้ทุกที่เพราะแล้วมันจะเป็นรุ่นที่ไม่ตรงกันอยู่แล้ว

อ่านเพิ่มเติม; อีกครั้งสตีเฟนอธิบายอย่างชัดเจนมาก:

ทำไมไม่ใช้ 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 ไม่ใช่สิ่งที่ OP ต้องการ
Guillaume Sasdy

2
@GillaillaumeSasdy: ผู้โพสต์ดั้งเดิมได้ตอบคำถามของงานนี้: มันเป็น IO มันไม่มีประโยชน์ที่จะเรียกใช้งาน IO ที่ถูกผูกไว้บนเธรดผู้ปฏิบัติงาน!
Eric Lippert

เอาล่ะฉันเข้าใจ ฉันคิดว่าคุณพูดถูกและ @StriplingWarrior ก็ช่วยอธิบายได้มากกว่านี้
Guillaume Sasdy

2
@Marko วิธีแก้ปัญหาเกือบทุกอย่างที่บล็อกเธรดในที่สุด (ภายใต้ภาระสูง) จะใช้เธรดพูลเธรดทั้งหมด (ซึ่งโดยปกติจะใช้เพื่อดำเนินการกับ async) ผลที่ได้คือเธรดทั้งหมดจะรอและจะไม่มีการอนุญาตให้การดำเนินการเพื่อเรียกใช้ส่วน "การดำเนินการ async เสร็จสมบูรณ์" ของรหัส วิธีแก้ปัญหาส่วนใหญ่ใช้ได้ในสถานการณ์โหลดต่ำ (เนื่องจากมีเธรดมากมายแม้กระทั่ง 2-3 บล็อกที่เป็นส่วนหนึ่งของการดำเนินการแต่ละครั้ง) ... และถ้าคุณสามารถรับประกันได้ว่าการดำเนินการ async เสร็จสิ้นบนเธรด OS ใหม่ (ไม่ใช่เธรดพูล) อาจทำงานได้กับทุกกรณี (คุณจ่ายในราคาสูง)
Alexei Levenkov

2
บางครั้งก็ไม่มีวิธีอื่นนอกจาก "เพียงแค่ทำซิงโครนัสเวอร์ชันบนเธรดผู้ปฏิบัติงาน" ตัวอย่างเช่นวิธีDns.GetHostEntryAsyncการใช้งานใน. NET และFileStream.ReadAsyncสำหรับไฟล์บางประเภท ระบบปฏิบัติการไม่มีอินเตอร์เฟสแบบอะซิงก์ดังนั้นรันไทม์จึงต้องทำการปลอม (และไม่ใช่ภาษาที่เฉพาะเจาะจง - กล่าวคือ Erlang รันไทม์รันทั้งกระบวนการของผู้ปฏิบัติงานโดยมีหลายเธรดภายในแต่ละเธรดเพื่อให้ดิสก์ I / O และชื่อที่ไม่บล็อก ความละเอียด)
Joker_vD

6

เป็นการยากที่จะให้คำตอบที่เหมาะกับทุกคน น่าเสียดายที่ไม่มีวิธีที่ง่ายและสมบูรณ์แบบในการนำรหัสมาใช้ซ้ำระหว่างแบบอะซิงโครนัสและซิงโครนัส แต่นี่คือหลักการบางประการที่ควรพิจารณา:

  1. รหัสแบบอะซิงโครนัสและซิงโครนัสมักแตกต่างกันโดยพื้นฐาน รหัสแบบอะซิงโครนัสควรมีโทเค็นการยกเลิกเช่น และบ่อยครั้งที่มันจบลงด้วยการโทรวิธีที่แตกต่าง (เช่นตัวอย่างของคุณโทรQuery()ในหนึ่งและQueryAsync()อื่น ๆ ) หรือการตั้งค่าการเชื่อมต่อด้วยการตั้งค่าที่แตกต่างกัน ดังนั้นแม้ว่ามันจะมีโครงสร้างที่คล้ายคลึงกัน แต่ก็มักจะมีพฤติกรรมที่แตกต่างกันพอสมควรที่จะให้พวกเขาได้รับการปฏิบัติเหมือนเป็นโค้ดแยกต่างหากที่มีข้อกำหนดที่แตกต่างกัน สังเกตความแตกต่างระหว่างการใช้งานAsyncและSyncของวิธีการในคลาส File ตัวอย่างเช่น: ไม่ต้องใช้ความพยายามในการทำให้ใช้รหัสเดียวกัน
  2. หากคุณกำลังให้ลายเซ็นวิธี Asynchronous เพื่อประโยชน์ในการใช้อินเตอร์เฟซ แต่คุณเกิดขึ้นจะมีการดำเนินการซิงโคร (คือไม่มีอะไรเนื้อแท้ async เกี่ยวกับสิ่งที่วิธีการของคุณไม่) 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
ดูคำตอบของฉันว่าทำไมนี่จึงเป็นวิธีปฏิบัติที่เลวร้ายที่สุดที่คุณต้องไม่ทำ
Eric Lippert

1
คำตอบของคุณเกี่ยวข้องกับ GetAwaiter (). GetResult () ฉันหลีกเลี่ยงสิ่งนั้น ความจริงก็คือบางครั้งคุณต้องทำให้วิธีการทำงานพร้อมกันเพื่อที่จะใช้ในการโทรสแต็คที่ไม่สามารถทำ async หากไม่ควรทำการซิงค์ให้รอดังนั้นในฐานะสมาชิก (อดีต) ของทีม C # โปรดบอกฉันว่าทำไมคุณไม่ใส่หนึ่งในสองวิธีในการรองานอะซิงโครนัสลงใน TPL
KeithS

คนที่ถามคำถามนั้นก็คือ Stephen Toub ไม่ใช่ฉัน ฉันทำงานด้านภาษาของสิ่งต่าง ๆ เท่านั้น
Eric Lippert
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.