اذهب إلى المحتوى

التواصل بين العمليات في البرمجة


أسامة دمراني

سنستخدم في هذا المقال الدالة os.fork()‎ التي لا توجد في نظام ويندوز، لأنه بطيء جدًا -موازنةً بأنظمة يونكس- في إنشاء العمليات الجديدة، إلى درجة عدم استخدام fork()‎ حتى لو استطعنا ذلك، لكن يمكن حل هذه المشكلة باستخدام الخيوط threads، التي تعمل على كل من يونكس وويندوز، وسنشرحها في مقال لاحق، فإذا كنت تستخدم ويندوز فيفضّل أن تقرأ القسم الخاص بالمفاهيم هنا لأنها ستُستخدم في المقال التالي، لكن لا داعي لكتابة الأمثلة الموجودة هنا لأنها لن تعمل معك، أما إذا كنت تستخدم أحد أنظمة يونكس فلن تواجهك مشكلةً فيها.

سنشرح في هذا المقال من سلسلة تعلم البرمجة:

  • الحالات التي تستدعي IPC وأسبابها.
  • أساسيات الأنابيب pipes.
  • نسخ العمليات باستخدام os.fork()‎.
  • التواصل من خلال الأنابيب.
  • إنهاء العمليات باستخدام os.kill()‎.

تعريف التواصل بين العمليات

التواصل بين العمليات inter-process communication واختصارًا IPC، هو آلية تمكّن عمليةً ما من التواصل وتبادل البيانات مع عملية أخرى، وقد شرحنا في المقال السابق: استخدام نظام التشغيل أن العملية برنامج تنفيذي، وأنها تستطيع التواصل مع غيرها من العمليات باستخدام المزايا الموجودة في وحدة subprocess، وعلى الرغم من أن هذه التقنيات مفيدة في التواصل مع البرامج الأخرى، إلا أنها لا توفر التحكم الدقيق المطلوب أحيانًا للتطبيقات الكبيرة، فمن الشائع في تلك التطبيقات استخدام عدة عمليات في نفس الوقت، بحيث تنفذ كل منها مهمةً مستقلةً، وتطلب بقية العمليات خدمات منها، فمثلًا قد يكون لخادم الويب عملية تراقب طلبات الويب من المتصفحات وتخدمها في صفحات HTML بسيطة، لكنه يستخدم عمليةً أخرى لتوفير طلبات البيانات الأعقد، وربما عملية أخرى لمعالجة طلبات ftp، وتُصمم كل عملية بحيث تنفذ مهمةً واحدةً فقط بكفاءة عالية، ويسمح هذا التصميم للمدير بمشاركة حمل المعالجة مع عدة عمليات، فإذا كان لدينا طلبات ftp كثيرة مثلًا، فيمكن بدء عملية ftp جديدة، وتوزيع طلبات ftp على العمليتين.

أهمية التواصل بين العمليات

مع أننا قد لا نكتب مثل تلك التطبيقات الكبيرة، إلا أنها تقنية مفيدة، وسنحتاج المفاهيم التي فيها في مقالات لاحقة، مثل فصل برمجة الشبكات، وقد نستفيد منها في برامج يتشارك فيها عدة مستخدمين أحد الموارد، كقاعدة بيانات مثلًا، حتى لو كانت تلك البرامج صغيرةً، وكذلك عند قراءة البيانات من عدة منافذ اتصال، حيث سيكون من الأفضل وجود عملية لكل اتصال شبكي، كي لا يؤثر انقطاع إحدى الشبكات على قراءة بقية الشبكات.

هل تشبه هذه التقنية تقنية العميل/الخادم؟

يكثر استخدام مصطلح العميل/الخادم في التطبيقات التجارية، وهو مصطلح خاص بمعمارية البرمجيات، ويشير إلى نوع محدد من إعدادات التواصل بين العمليات، ترسل فيه إحدى العمليات -وهي العميل client- طلبات إلى عملية أخرى -هي الخادم server-، لكن الخادم لا يطلب أي شيء أبدًا من العميل، وقد يكون لدينا عدة عمليات من نوع "العميل" تصل إلى خادم واحد، ويمكن لذلك الخادم نفسه أن يكون عميلًا لخوادم أخرى، فيما يعرف بحوسبة العميل/الخادم متعدد المستويات N-Tier Client/Server، التي تستخدم التواصل بين العمليات، إلا أنه لا يُشترط أن تكون تقنية التواصل بين العمليات مبنيةً على تقنية العميل/الخادم، ومن الممكن أن يكون لدينا شبكة من العمليات ترسل كل منها رسائل إلى بقية العمليات دون أي تخطيط ثابت بين العملاء والخوادم، ويُسمى هذا بحوسبة النِد للنِد peer-to-peer computing، وقد يبدو هذا نموذجًا جذابًا إلا أنه صعب الإدارة إذا زاد عدد العمليات، لذا يظل النموذج التقليدي للعميل/الخادم هو الأكثر استقرارًا والأسهل في تصميمه وتشغيله.

هل لهذه التقنية علاقة بالعتاد؟

تكلمنا في وصفنا السابق للعميل والخادم عن البرمجيات والعمليات فقط، لكن ربما تكون قد سمعت هذه المصطلحات في العتاد أيضًا، خاصةً مصطلح الخادم server الذي يشير إلى الحواسيب القوية التي يتشاركها عدة مستخدمين، وهذه إساءة إلى المعنى الحقيقي لحوسبة العميل/الخادم التي هي معمارية برمجية خالصة، ومستقلة عن العتاد.

تميل عمليات الخوادم في الواقع العملي إلى العمل على حواسيب مستقلة وقوية، ويُشار إلى الجمع بين العتاد وعمليات الخوادم باسم الخادم، ويأخذنا هذا الأسلوب من استخدام تقنية العميل/الخادم إلى عالم حوسبة الشبكات الذي ننظر فيه في المقال التالي.

يمكن ملاحظة تشابه هذه المصطلحات مع تلك المستخدمة في البرمجة كائنية التوجه، لأن استخدام الرسائل بين العمليات يشبه إرسال الرسائل بين الكائنات في البرمجة الكائنية، وفعليًا توجد الكثير من الأمور المشتركة بين النموذج الكائني ونموذج التواصل بين العمليات، وقد طُورت بعض معماريات IPC لتستفيد من هذه الأوجه المشتركة، مثل معمارية وسيط طلب الكائن المشترك Common Object Request Broker أو COBRA اختصارًا، حيث نسجل الكائنات في تلك المعمارية مع وسيط طلب لكائن مركزي ORB، وتُوجَّه الرسائل المرسَلة إلى الكائن من أي مكان في الهيكل إلى العملية التي سجلت الكائن، ولن ننظر فيها هنا، ويمكن العودة إلى تطبيقات بايثون لوسطاء طلبات الكائنات المركزية ORB لمزيد من المعلومات.

سنبدأ الآن كتابة بعض الشيفرات، لكن يجب أن ننظر أولًا في بعض آليات التواصل بين العمليات، وسندرس اثنتين منها، الأولى هي تقنية الأنبوب pipe المستخدمة لتبادل البيانات بين عمليتين.

تعريف الأنبوب Pipe

يمكن النظر إلى الأنبوب Pipe على أنه يشبه الخرطوم، حيث نسكب البيانات في أحد طرفيه ليخرج من الآخر، وهو يشبه الملف من حيث التعامل معه على أنه تدفق متسلسل للبيانات، لكنه يختلف عن الملف في أن له طرفين، لذا نحصل عند إنشائه على نقطتي نهاية، لنقرأ من إحداهما، ونكتب إلى الأخرى. وعلى عكس الملف لا تُخزّن البيانات حقيقةً عند إغلاق الأنبوب، لذا سنفقد كل ما كتبناه في أحد طرفي الأنبوب ولم نقرأه من الجانب الآخر.

يمكن توضيح استخدام الأنابيب مثل قنوات بين العمليات كما يلي:

pipes.png

نرى هنا عمليتين سميناهما Parent وChild، لأسباب ستبينها بعد قليل، وتستطيع عملية Parent أن تكتب في Pipe A، وتقرأ من Pipe B، أما عملية Child فتستطيع القراءة من Pipe A والكتابة في Pipe B، ويُستخدم كل أنبوب لإرسال طلب أو إعادة بيانات وفقًا للعملية التي بدأت عملية التبادل، ويعطينا ذلك تحديًا مثيرًا للاهتمام في سبب تسمية الأنابيب.

سنكتب الآن بعض التعليمات البرمجية لنرى كيف يمكن بناء آلية IPC باستخدام بايثون، وسيكون المبدأ العام هو إنشاء عملية تكون أصلًا parent وتفتح أنبوبين، ثم نشتق نسخةً منها لتكون فرعًا فيه نفس الأنبوبين، ثم نستخدم الأنابيب للتواصل بين الأصل والفرع، وأول ما سنفعله هو معرفة كيفية استخدام الأنابيب لإرسال البيانات واستلامها، حيث سننشئ أنبوبًا باستخدام دالة os.pipe()‎ تعيد اثنين من واصفات الملفات، واحد لكل طرف من أطراف الأنبوب، ثم نستخدم دوال os.read/write لإرسال البيانات في الأنبوب:

import os

# أنشئ الأنبوب
receive, transmit = os.pipe()

data = 'Here is a data string'
length = os.write(transmit, bytes(data, 'utf8'))
print('Length of data sent: ', length)

# 1024 مخزن مؤقت حجمه 
# لضمان استقبال جميع البيانات
print( 'The pipe contains:', os.read(receive, 1024).decode('utf8') )

لاحظ أننا نحتاج إلى تحويل البيانات من وإلى مصفوفات البايتات byte arrays، تحويلًا مشتركًا مع معظم عمليات مستوى النظام، ويحدث كل ذلك في عملية واحدة، لذا لا يكون ذا فائدة كبيرة، لكننا نستطيع فصل شيفرة القراءة والكتابة والبدء في تنفيذ شيء ما، بمجرد استنساخ عمليتنا، فكيف ننشئ عملية الاستنساخ تلك؟

عمليات الاستنساخ

إن الآلية المستخدمة في توليد الفروع spawning أو اشتقاقها forking هي استخدام استدعاء النظام os.fork()‎ الذي يعيد قيمًا مختلفةً وفقًا لمكاننا في العملية الأصل أو الفرع، وتكون قيمة الإعادة في العملية الأصلية هي معرِّف العملية أو pid للعملية الفرع، اما إذا كنا في العملية الفرع فستكون قيمة إعادة fork صفرًا، وهذا يعني أنه سيكون لدينا في الشيفرة تعليمة if تتحقق من قيمة إعادة fork()‎، فإن كانت صفرًا فستنفَّذ دوال العملية الفرع، وإن لم تكن كذلك فستنفَّذ دوال العملية الأصل، ويفضَّل وضع الدوال في وحدات منفصلة واستدعاؤها حسب الحاجة، للسيطرة على مجريات التنفيذ، إلا أننا لن نفعل ذلك هنا لأن الشيفرة قصيرة وستكفينا قائمة واحدة، ونعيد التذكير هنا أن ويندوز لا يفعل هذا، أي لا يدعم دالة fork()‎، لذا لن تعمل الشيفرة في ويندوز، وسيضطر مستخدم ويندوز إلى الانتظار إلى الفصل التالي ليعرف كيفية كتابة برامج العميل/الخادم.

سننشئ الآن عمليةً فرعيةً تستطيع إجراء عملية بسيطة لتنسيق النصوص، وتعيد القيمة التي نمررها إليها مسبوقةً ومتبوعةً بالجملة Ni.

import os,signal

# أنشئ الأنابيب

ServerReceive,ClientSend = os.pipe()
ClientReceive, ServerSend = os.pipe()

pid = os.fork()
if pid == 0:  # في الفرع
   while True: # قدمها بلا نهاية
      data = os.read(ServerReceive,1024)
      if data:  # تحقق من استلام شيء ما!
         data = 'Ni!\n' + data + '\nNi!'
      else: data = "Error: received empty message"
      os.write(ServerSend,data)
else: # في الأصل
   data = ['The Knights who say Ni!',
           'Appear in the film "Monty Python and the Holy Grail" ']
   for line in data:
       os.write(ClientSend,line)
       print os.read(ClientReceive,1024)

   # والآن أنه العملية الفرع
   os.kill(pid,signal.SIGTERM) 

لاحظ أننا نستخدم pid الذي استلمناه من fork لإنهاء العملية الفرع، فإذا فشلنا في ذلك فستصبح العملية الفرع عمليةً خلفيةً أو خفيةً daemon، تعمل بصمت بلا نهاية بانتظار ظهور البيانات على أنبوب الدخل الخاص بها، أما الإنهاء الفعلي فيكون باستخدام دالة os.kill()‎، التي تُستخدم لإرسال أي رسالة إلى أي عملية -بغض النظر عن اسمها-، وتسمى إشارة الإنهاء هنا SIGTERM كما هو محدد في وحدة signal، وتختلف القائمة الكاملة وفقًا لكل منصة، وتكون موثقةً في مكتبة libc الخاصة بلغة C لمنصتك، لكن يمكن الحصول عليها بسهولة كما يلي في محث بايثون:

>>> dir(signal)

أو في سطر أوامر يونكس (لاحظ أن هذا حرف L وليس العدد 1):

$ kill -l

ونحصل في حالة يونكس على القيمة العددية التي يمكن استخدامها في أمر kill()‎ مباشرةً، لكن هذه القيمة لن تعمل في منصات مختلفة.

أنشأنا الأنابيب ونقلنا البيانات عبرها، كما أنشأنا عمليةً فرعيةً وأرسلنا البيانات إليها، وعدلنا البيانات في العملية الفرعية وأرسلنا البيانات إلى العملية الأصل، وأخيرًا أنهينا العملية الفرع، وهذا كل ما نحتاج إليه في التواصل الأساسي بين العمليات، أما الآن فسنرى مثالًا حقيقيًا عمليًا، نعود فيه إلى برنامج دليل جهات الاتصال مرةً أخرى.

دليل جهات الاتصال بتقنية العميل/الخادم

بنينا نسخةً من دليل جهات الاتصال في فصل الملفات باستخدام قاموس، وسنعيد استخدام ذلك المثال لكننا سنبني هذه المرة نسخة العميل/الخادم.

لاحظ أن الشيفرة الأصلية كسرت إحدى قواعد الممارسة السليمة التي ذكرناها من قبل، وهي أننا أدرجنا شيفرة واجهة المستخدم في دوالنا المساعِدة، فإذا كنا سنستخدم تلك الشيفرة مرةً أخرى فسنحصل على رسائل مختلطة من العملية الفرعية والعملية الأصل، ونريد أن نعدل الشيفرة قليلًا كي نحولها إلى نموذج يمكن استخدامه، والمهم هنا إزالة أي تعليمة print من الدوال، وتمرير البيانات وسطاء، كما نريد إعادة النتيجة من كل دالة، بمجرد إنهاء ذلك نستطيع استيراد الشيفرة والوصول إلى الدوال المساعدة دون تنفيذ دالة main()‎، وعلى ذلك تكون الدوال التي سنوفرها هي:

  • readBook(filename)‎
  • saveBook(book, filename)‎
  • addEntry(book, name, data)‎
  • removeEntry(book, name)‎
  • findEntry(book, name)‎

وستبدو الشيفرة المعدلة كما يلي:

def readBook(filename='addbook.dat'):
    import os
    book = {}
    if os.path.exists(filename):
       store = open(filename,'r')
       for line in store:
          name,entry = line.strip().split(':')
          book[name] = entry
    else:
        store = open(filename,'w') # أنشئ ملفًا فارغًا جديدًا
    store.close()
    return book

def saveBook(book, filename = "addbook.dat"):
    store = open(filename,'w')
    for name,entry in book.items():
        line = "%s:%s" % (name,entry)
        store.write(line + '\n')
    store.close()

def addEntry(book, name, data):
    book[name] = data
    return 'Added entry for ' + name

def removeEntry(book, name):
    del(book[name])
    return 'Deleted entry for ' + name

def findEntry(book, name):
    if name in book.keys():
       result = "%s : %s" % (name, book[name])
    else: result = "Sorry, no entry for: " + name
    return result

لاحظ أننا تجاهلنا دوال واجهة المستخدم لأننا لا نحتاج إليها هنا، لكن قد نرغب في إجراء التعديلات المناسبة للسماح لها بالعمل مثل برنامج منفصل، يكون وحدةً لنسخة العميل/الخادم، ولتكتب ذلك ليكون تدريبًا عمليًا لك.

بمجرد أن نصلح الشيفرة وتكون صالحةً للعمل في برنامج مستقل، أو بحفظ الشيفرة أعلاه في ملف address_srv.py، وهو ما فعلناه، نستطيع أن نتابع كتابة شيفرة العميل/الخادم، وستتكون من الهيكل القياسي لإنشاء الأنابيب واشتقاق العملية، حيث سنقرأ في العملية الفرعية الأنبوب الوارد، ونفسر البيانات على أنها أمر متبوع بالوسطاء التي توفرت، ثم نستدعي دالة قاعدة البيانات المناسبة، وسنعرض -سواء في العملية الأصل أو الفرع- على المستخدم قائمةً، ونطلب أي بيانات إضافية وفقًا لما اختير قبل إرسال سلسلة البيانات المدمجة إلى العملية الفرع أو الخادم، ثم يقرأ العميل الاستجابة ويقدمها إلى المستخدم، وسيبدو البرنامج الرئيسي كما يلي:

import os, signal, address_srv

fromClient,toServer = os.pipe()
fromServer,toClient = os.pipe()

pid = os.fork()
if pid == 0:   # نحن الخادم
   addresses = address_srv.readBook()
   while True:
      s = os.read(fromClient,1024)
      cmd,data = s.split(':')
      if cmd == "add":
         details = data.split(',')
         name = details[0]
         entry = ','.join(details[1:])
         s = address_srv.addEntry(addresses, name, entry)
         address_srv.saveBook(addresses)
      elif cmd == "rem":
         s = address_srv.removeEntry(addresses, data)
         address_srv.saveBook(addresses)
      elif cmd == "fnd":
         s = address_srv.findEntry(addresses, data)
      else: s = "ERROR: Unrecognized command: " + cmd
      os.write(toClient,s)
else:     # We are the client
   menu = '''
   1) Add Entry
   2) Delete Entry
   3) Find Entry

   4) Quit
   '''
   while True:
      print( menu )
      try: choice = int(input('Choose an option[1-4] '))
      except: continue
      if choice == 1:
         name = input('Enter the name: ')
         num = input('Enter the House number: ')
         street= input('Enter the Street name: ')
         town = input('Enter the Town: ')
         phone = input('Enter the Phone number: ')
         data = "%s,%s,%s,%s,%s" % (name,num,street,town,phone)
         cmd = "add:%s" % data
      elif choice == 2: 
         name = input('Enter the name: ')
         cmd = 'rem:%s' % name
      elif choice == 3: 
         name = input('Enter the name: ')
         cmd = 'fnd:%s' % name
      elif choice == 4: 
         break
      else:
         print( "Invalid choice, must be between 1 and 4." )
         continue

      os.write(toServer, cmd)
       print( "\nRESULT: ", os.read(fromServer,1024) )

   os.kill(pid, signal.SIGTERM)

لا شك أننا نستطيع ترتيب تلك الشيفرة لتكون أفضل باستخدام بعض دوال سلاسل if/elif، لكن المثال أصغر من أن يستدعي ذلك، ويجب ملاحظة بعض النقاط هي:

  • استخدام break لإيقاف الحلقة التكرارية.
  • استخدام continue للعودة إلى قمة الحلقة مرةً أخرى.

وقد شرحنا ذلك باختصار في فصل مقدمة في البرمجة الشرطية، ويُرجع إلى توثيق كل منها للمزيد من التفاصيل.

من الممكن توسيع هذا التدريب ليستخدم نسخة قاعدة البيانات التي شرحناها من قبل لتكون هي الخادم بدلًا من نسخة القاموس، وسنترك ذلك تدريبًا للقارئ.

تكمن العقبة في مثل هذا النموذج من برمجة العميل/الخادم في أن الخادم لا يستطيع التحدث إلا مع العميل الذي بدأه، مع أنه يسهل إنشاء آلية لإنتاج عمليات إضافية تدرك أنها يجب أن تتصرف مثل عملاء لا خوادم، وكان من الأفضل بدء تشغيل الخادم أولًا ثم إلحاق عدة عملاء به، وهو ما سنفعله في نهاية المقال التالي حيث نشرح الشبكات بمزيد من التفصيل.

خاتمة

في نهاية هذا المقال نأمل أن تكون تعلمت ما يلي:

  • تستطيع العمليات أن تتواصل فيما بينها بواسطة الأنابيب.
  • تستطيع العمليات الأصل أن تستنسخ نفسها باستخدام os.fork.
  • يجب إنهاء العمليات الفرعية وإلا ستعمل بلا نهاية، وتستهلك موارد الحاسوب، وننهيها باستخدام دالة os.kill.

ترجمة -بتصرف- للفصل السادس والعشرين: Inter-Process Communication من كتاب Learn To Program لصاحبه Alan Gauld.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...