دانستنی‌ها

ورودی/خروجی مسدودکننده و غیر مسدودکننده در جاوا

سورس برنامه‌هایی که در این مقاله نوشته شده‌اند در این ریپوزیتوری گیتهاب قرار دارد.

در برنامه‌های client و server، وقتی کاربر یک درخواست به سرور می‌فرستد، ابتدا سرور آن درخواست را پردازش می‌کند و سپس پاسخی مناسب برای کاربر می‌فرستد. برای اینکه این «درخواست و پاسخ» اتفاق بیافتد، باید در ابتدا یک راه ارتباطی بین کاربر و سرور برقرار شود. در اینجا از سوکت‌ها استفاده می‌شود. برای برقراری ارتباط، کاربر و سرور هر دو به دو سر یک سوکت وصل می‌شوند. سرور گوش به زنگ سوکت می‌نشیند که اگر کاربر درخواستی فرستاد، به آن پاسخ بدهد.

ارتباط بین کلاینت و سرور

زمانی که ارتباط بین کاربر و سرور شکل گرفت، هر دو می‌توانند از دو سر سوکت بخوانند و بنویسند (پروتوکل TCP بر اساس شماره‌ پورت، مشخص می‌کند بسته‌ای که کارت شبکه دریافت کرده مربوط به کدام نرم‌افزار است و باید تحویل کدام برنامه شود).

I/O در دنیای کامپیوتر به معنای ورودی/خروجی (Input/Output) بوده و مربوط به نحوه برقراری ارتباط بین سیستم‌ها است.

ورودی/خروجی مسدودکننده (Blocking I/O)

در این حالت وقتی کاربر درخواست برقراری ارتباط با سرور را می‌دهد، نخ پردازشی (Thread) که وظیفه برقراری ارتباط را به عهده دارد باید تا زمانی که داده‌ای روی آن برای خواندن وجود دارد یا تا زمانی که داده به طور کامل نوشته شده باشد، شود صبر کند (صبر کردن همان مسدود شدن است). در این حالت، برای اینکه بتوان درخواست‌های همزمان را پردازش کرد، باید به هر ارتباط کاربر، یک نخ پردازشی جدید اختصاص داد. بیایید این موضوع را با یک تکه‌کد بررسی کنیم:

در اینجا یک شی سرور‌سوکت ساخته شده است که به درخواست اتصالات در یک پورت مشخص گوش می‌دهد. در این حالت این پورت سرور به برنامه ما مرتبط شده است.

ServerSocket serverSocket = new ServerSocket(portNumber);

باز کردن سرور سوکت

سپس از متد ()accept استفاده می‌شود. سرور منتظر کاربری می‌ماند که روی آن پورت خاص سرور، یک کانکشن ایجاد کند و وقتی روی آن کانکشن یک درخواستی بدهد، سرور‌سوکت اتصال را می‌پذیرد و یک سوکت جدید برای ارتباط با آن کاربر برمی‌گرداند. در حین شکل‌گیری این ارتباط، سرورسوکت مسدود می‌شود اما پس از برقراری اتصال، به اتصالات کاربران جدید از طریق سرور‌سوکت اصلی، گوش می‌دهد.

Socket clientSocket = serverSocket.accept();

اتصال کاربر به سرور سوکت و سپس ساخت یک سوکت جدید برای ارتباط با این کاربرسرور سوکت آزاد می‌شود و یک سوکت مخصوص ارتباط با این کاربر ساخته می‌شود.

حالا می‌توان جریان ورودی و خروجی را از سوکت گرفت.

BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

PrintWriter out =
new PrintWriter(clientSocket.getOutputStream(), true);

یک خط از جریانِ داده‌ِ ورودی که فرستنده برای ما فرستاده، خوانده و پردازش می‌شود. سپس پاسخ به کاربر در جریان خروجی‌ای که به سوکت وصل است نوشته می‌شود. این کار تا زمانی ادامه پیدا می‌کند که کاربر به سرور، “Done” را بفرستد یا Cntrl-C را فشار دهد و کاراکتر «اتمام ورودی» را وارد کند.

String request, response;
while ((request = in.readLine()) != null) {
  response = processRequest(request);
  out.println(response);
  if ("Done".equals(request)) {
    break;
  }
}

حال نکته مهم این است که این کد تا به اینجا، فقط برای یک اتصال در لحظه کار می‌کند. برای اینکه بتوان چند کاربر موازی همزمان داشت، باید به ازای هر اتصال یک نخ اجرایی ایجاد شود.

while (listening) {
    accept a connection;
    create a thread to deal with the client;
}

سورس‌کد کامل برنامه کلاینت و سرور در حالت ورودی/خروجی مسدودکننده در این لینک قابل دسترس است.

چندین کلاینت با یک سرور در ارتباط هستند

در این رویکرد اشکالاتی وجود دارد:

۱- هر نخ پردازشی یک استک از حافظه را اشغال می‌کند. حال زمانی که تعداد اتصالات زیاد شود، ایجاد نخ‌های جدید و جابه‌جایی بین آن‌ها مشکل‌زا خواهد شد. 

۲- در حالاتی ممکن است چند نخ در حالت انتظار برای درخواست از سمت کاربر باشند و از آن‌ها استفاده‌ای نشود که نوعی اسراف منابع به حساب می‌آید. 

در نهایت روش مسدود کردن ورودی/خروجی برای زمان‌هایی که تعداد کاربران زیاد است، ایده‌آل نیست ولی اگر تعداد کاربر‌ها کم تا متوسط باشد، این روش پاسخگو خواهد بود. 

اگر نیاز به پشتیبانی تعداد زیادی کاربر به طور همزمان داریم، چه کار باید کرد؟ خوشبختانه راه‌حلی برای این مسئله وجود دارد.

ورودی/خروجی غیر مسدودکننده (Non-blocking I/O)

ورودی خروجی غیرمسدود کننده

با ورودی/خروجی غیر مسدودکننده، می‌توان چند اتصال را به طور همزمان با استفاده از یک نخ پردازشی، مدیریت کرد. قبل از اینکه به جزئیات بپردازیم، ابتدا بیایید چند مفهوم را مرور کنیم:

۱- در سیستم‌هایی که بر پایه ورودی/خروجی غیرمسدودکننده (مثلا پکیج NIO جاوا) هستند، به جای داشتن جریان داده‌های ورودی و خروجی،‌ داده‌ها در بافر (Buffer) خوانده و نوشته می‌شوند. می‌توان به بافر به عنوان یک فضای ذخیره موقت نگاه کرد. کلاس‌های مختلفی برای پیاده‌سازی بافر در NIO جاوا وجود دارد (برای مثال  ByteBuffer , CharBuffer , ShortBuffer اما معمولا از ByteBuffer در برنامه‌های تحت شبکه استفاده می‌شود.)

۲- کانال (channel) واسطه‌ای است که داده‌ها را به داخل و خارج از بافر منتقل می‌کند و می‌توان به آن، به عنوان نقطه‌ای پایانی برای ارتباط نگاه کرد. (برای مثال کلاس «SocketChannel» را در نظر بگیرید. این کلاس از سوکت‌های TCP می‌خواند و داخشان می‌نویسد اما داده‌ها باید به شکل اشیایی از کلاس بایت‌بافر (ByteBuffer) تبدیل شوند.)

۳-سپس باید با مفهومی به نام «انتخاب براساس آمادگی (Readiness Selection)» آشنا شویم که به معنای انتخاب سوکتی است که هنگام خواندن و نوشتن داده‌ها مسدود نمی‌شود. 

جاوا یک کلاس یه نام «Selector» دارد که این کلاس به یک نخ پردازشی اجازه می‌دهد رویدادهای ورودی/خروجی را در چند کانال بررسی کند. یعنی این کلاس می‌تواند آمادگی یک کانال را برای خواندن و نوشتن بررسی کند. کانال‌های مختلفی را می‌توان به یک شی Selector وصل کرد. همچنین باید به یاد داشته باشیم که به هر یک از این کانال‌ها یک کلید انتخاب (SelectionKey) نسبت داده می‌شود که مانند یک اشاره‌گر به آن کانال عمل می‌کند.

معماری استفاده از selector

حالا وقت آن است که کمی پیاده‌سازی انجام دهیم. ابتدا کد بخش سرور را می‌نویسیم:

ابتدا باید یک شی انتخاب‌کننده (selector) ساخته شود که چندین کانال را مدیریت کند. در این صورت سرور می‌تواند همه اتصالاتی که آماده دریافت خروجی یا ارسال ورودی هستند را پیدا کند. 

Selector selector = Selector.open();

سپس باید یک کانال سرور‌سوکت با رویکرد غیر مسدودشونده ایجاد کرد. کلاس «ServerSocketChannel» مسئول پذیرش اتصالات ورودی جدید است.

ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);

سپس یک هاست و پورت به کانال سرور‌سوکت اختصاص می‌یابد.

InetSocketAddress hostAddress = new InetSocketAddress(hostname, portNumber);

serverChannel.bind(hostAddress);

در ادامه، کانال سرورسوکت باید توسط انتخاب‌کننده ثبت شود. پارامتر SelectionKey.OP_ACCEPT، به انتخاب‌کننده می‌گوید که فقط به اتصالات ورودی گوش کند. پارامتر دوم، نشان می‌دهد که چه دسته از رویدادهایی در کانالِ تحتِ نظارت برای ما مطلوب است. در اینجا ‘‘OP ACCEPT” به این معنا است که کانال سرورسوکت آماده پذیرش اتصال جدید از سمت کاربر است.

serverChannel.register(selector, SelectionKey.OP_ACCEPT);

سپس متد ()select صدا زده می‌شود تا بررسی شود که آیا چیزی برای انجام کار آماده است یا خیر. اگر بخواهیم که به طور نامحدود منتظر فعالیت جدید باشیم، می‌توان این تابع را درون یک حلقه بی‌نهایت صدا زد. «ReadyCount» تعداد کانال‌های آماده را نشان می‌دهد. اگر تعداد کانال‌های آماده صفر باشد، به انتظار ادامه داده می‌شود. 

while (true) {
   int readyCount = selector.select();
   if (readyCount == 0) {
      continue;
   }   // process selected keys...
}

زمانی که انتخاب‌کننده یک کانال آماده پیدا می‌کند، متد ()selectedKeys یک مجموعه از کلیدهای آماده برمی‌گرداند که هر عضو آن نماینده یک کانال آماده است. می‌توان روی هر کانال یک حلقه زد و عملیات مورد نیاز را انجام داد. 

// process selected keys...
Set readyKeys = selector.selectedKeys();
Iterator iterator = readyKeys.iterator();
while (iterator.hasNext()) {
  SelectionKey key = iterator.next();
  // Remove key from set so we don't process it twice
  iterator.remove();
  // operate on the channel...
}

نکته‌ای که در اینجا باید به آن توجه کرد که این است که در این حالت فقط یک نخ پردازشی وجود دارد که همان نخ اصلی بوده و چندین اتصال همزمان را مدیریت کرده و پردازش‌های لازم را انجام می‌دهد. 

در مرحله بعد، منطق عملیاتی برنامه‌مان را پیاده‌سازی می‌کنیم. کانالی که توسط SelectionKey مشخص می‌شود، می‌تواند سوکت‌سرور باشد که به شما اطلاع می‌دهد اتصال جدیدی برقرار شده است یا سوکت کاربری باشد که آماده خواندن یا نوشتن داده‌ها در کانال است.

اگر کلید «قابل قبول (acceptable)» باشد به این معناست که کاربری می‌خواهد اتصال جدید ایجاد کند.

 // operate on the channel...
 // client requires a connection
    if (key.isAcceptable()) {
     ServerSocketChannel server = (ServerSocketChannel)  key.channel();
      // get client socket channel
      SocketChannel client = server.accept();
      // Non Blocking I/O
      client.configureBlocking(false);
      // record it for read/write operations (Here we have used it for read)
      client.register(selector, SelectionKey.OP_READ);
      continue;
    }

اگر کلید «قابل خواندن (readable)» باشد، به این معناست که سرور آماده است که داده را از کاربر بخواند.

    // if readable then the server is ready to read
    if (key.isReadable()) {

      SocketChannel client = (SocketChannel) key.channel();

      // Read byte coming from the client
      int BUFFER_SIZE = 1024;
      ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
      try {
        client.read(buffer);
      }
      catch (Exception e) {
        // client is no longer active
        e.printStackTrace();
        continue;
      }

اگر کلید «قابل نوشتن (writable)» باشد، به این معناست که سرور می‌تواند داده‌ای را برای کاربر بنویسد. 

if (key.isWritable()) {
  SocketChannel client = (SocketChannel) key.channel();
  // write data to client...
}

حال در ادامه برنامه‌ای ساده برای سمت کاربر (‌‌client) نشان داده می‌شود. 

در ابتدا یک سوکت‌کانال (socket channel) برای ارتباط با سرور ساخته می‌شود. 

SocketAddress address = new InetSocketAddress(hostname, portnumber);
SocketChannel client = SocketChannel.open(address);

سپس به جای استفاده از جریان ورودی و خروجی سوکت، داده‌ها در خود کانال نوشته می‌شوند. همانطور که می‌دانیم داده‌ها برای نوشته شدن در کانال، ابتدا باید به شکل شی بایت‌بافر (ByteBuffer)، تحویل شوند. در اینجا یک بایت‌بافر با ظرفیت ۷۴ بایت ساخته شده است.

ByteBuffer buffer = ByteBuffer.allocate(74);

در نهایت، پیام کاربر در بافر نوشته شده و بافر آن را در کانال می‌نویسد.

buffer.put(msg.getBytes());
buffer.flip();
client.write(buffer);

سورس‌کد کامل ورودی/خروجی غیر مسدودکننده (NIO)، را به طور کامل در اینجا مطالعه کنید.

این مقاله برای کمک به درک مفاهیم ورودی و خروجی مسدودکننده و غیر مسدودکننده است و در این راستا با استفاده از جاوا NIO یک برنامه کلاینت-سروری ساده نوشته شد. اما در نظر داشته باشید به جای اینکه برنامه خود را مستقیما با استفاده از جاوا NIO پیاده‌سازی کنید، می‌توانید از چارچوب‌های نرم‌افزاری قابل اعتماد و پربازده‌ای مثل netty استفاده کنید.

منبع: وبلاگ Rukshani Athapathu در مدیوم

.

.

.

.

با ما همراه باشید


آدرس کانال تلگرام: JavaCupIR@

آدرس اکانت توییتر: JavaCupIR@

آدرس صفحه اینستاگرام: javacup.ir

صفحه ویرگول: javcup

آدرس گروه لینکدین: Iranian Java Developers

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا