শুরুর আগে

I can do them all at once.
Credit: http://bit.ly/2kIzy2a

উপরের দৃশ্যের সাথে আমরা সবাই কম বেশি পরিচিত। ছোট বেলায় বাবা/মা বাজার থেকে আসলে, হাতের সব ব্যাগ একবারে নিয়ে আসা ছিল অসীম শক্তির পরিচয়। একই সাথে ১ বারে আনতে পারলে সময়ও বেশি বাঁচবে। ছোট থেকেই সময় সচেতন আমরা। একই সাথে একাধিক কাজ করতে পছন্দ করি।

মডার্ন CPU তেও একই সাথে একাধিক কাজ করা সম্ভব বলেই আমরা একই সাথে গান শোনা, ওয়েব ব্রাউজ করা সহ আরও অনেকগুলো কাজ করতে পারি। CPU তে থাকে Physical core, এসকল core ব্যবহার করে Operating System (Windows, Linux, OS) এক সাথে অনেকগুলো কাজ পাশাপাশি করতে সক্ষম হয়। একই ভাবে আমরা পাইথন ব্যবহার করেও একই সাথে অনেকগুলো কাজ করতে পারি। পাইথন ছাড়াও অন্যান্য High Level Programming Language দিয়েও একই সাথে একাধিক কাজ করা সম্ভব। একে বলা হয়ে থাকে multi-threaded programming।

কী লাগবে?

এই ব্লগটি শুরু করার আগে কিছু জিনিস নিশ্চিত হয়ে নেয়া জরুরী। এই ব্লগটি যারা প্রোগ্রামিং-এ একেবারেই নতুন তাদের জন্য না। তবে, তারা  ফলো করে যদি কিছু শিখে নিতে পারে তবে তা খারাপ না। যাদের ন্যূনতম পাইথনের ধারনা আছে বা পাইথনে আগে প্রোগ্রামিং এর অভিজ্ঞতা আছে তারা আশা করি নতুন কিছু শিখতে পারবে। এই ব্লগ এর জন্য Python 3.7+ ইন্সটল থাকা ছাড়া আর কিছু জরুরী নয়।

পাইথন ল্যাঙ্গুয়েজে মাল্টিথ্রেডিং দেখার আগে আমাদের কিছু প্রাথমিক জ্ঞান প্রয়োজন

  • Physical core: কম্পিউটারের সকল instruction মূলত execute হয় CPU এর core এ। CPU এর core একটি physical hardware যা আসলে চোখে দেখা সম্ভব এমনকি ধরাও সম্ভব। আধুনিক CPU একসাথে একাধিক কাজ করার জন্য একাধিক core এর সমন্বয়ে তৈরি হয় । যেমন এই Core i7 প্রসেসরটিতে চারটি কোর আছে।
  • Process: একটি সম্পূর্ন প্রোগ্রাম যা কোনো একটি কাজ করতে সক্ষম। যেমনঃ ব্রাউজার, এমনকি যেকোনো একটি পাইথন স্ক্রিপ্টও যখন রান করা হয় তা সিস্টেমে একটি প্রসেস হিসেবে গণ্য করা হয়।
  • Thread: সহজ ভাবে কম্পিউটার প্রোগ্রামিং এ থ্রেড বলতে বোঝায় যেকোনো একটি ইন্সট্রাকশন সেট এর linear execution। অর্থাৎ সেট এর সকল ইন্সট্রাকশন একের পর এক সম্পন্ন হবে। নিচের ফ্লোচার্টটি খেয়াল করলে আমরা দেখতে পারি-
A flowchart for adding 2 number from input.
Credit: http://bit.ly/2lMY1nr

এখানে সম্পূর্ন কাজটি একের পর এক সম্পন্ন হচ্ছে। এই পুরো কাজটিকে আমরা একটি থ্রেড বলতে পারি। এখানে শর্ত হচ্ছে প্রোগ্রামে ইনপুট না নেয়া পর্যন্ত এটি পরের ধাপে যেন না যায়। এখন ধরা যাক, একটি প্রোগ্রাম, যা কিনা একটি ফাইল ডাউনলোড করতে সক্ষম, সেটি যদি ডাউনলোড করার সম্পূর্ন সময় ব্যবহারযোগ্য না থাকে তাহলে উক্ত প্রোগ্রামটি খুব একটা কাজের প্রোগ্রাম হবে না। এর জন্য আমাদের যেটি করতে হবে সেটি হচ্ছে ডাউনলোড এর কাজটি একটি ভিন্ন থ্রেড হিসেবে ব্যবহার করতে পারতে হবে।

তাহলে প্রোগ্রামটি দাড়াঁবে এমন -

A simple multi-threaded program example
Credit: http://bit.ly/2kFYU0L

ফ্লোচার্টটি একটি খুবই সাধারন মাল্টিথ্রেডেড প্রোগ্রামিং এর উদাহরণ।

উদাহরণ চাই

থ্রেডিং এবং মাল্টিথ্রেডিং বোঝার জন্য আমরা খুব সহজ একটি ছোট প্রোগ্রামের উদাহরণ দেখতে পারি -

def task(number, length):
    print(f'Task {number} started: ')
    for i in range(length):
        print(f'Task {number} prints {i}')

for i in range(4):
    task(i, 10000)

এটি একটি খুব সাধারণ প্রোগ্রাম যা ১ থেকে ১০০০০ পর্যন্ত প্রিন্ট করবে ৪ বার। এটি একটি linear program যা কিনা প্রতিটি ফাংশনকে পর পর কল করে। অর্থাৎ এটি একটি থ্রেডে কাজ করছে। একে যদি আমরা একাধিক থ্রেডে রান করতে চাই তাহলে আমরা কোডটিকে এভাবে লিখতে পারি-

import threading

def task(number, length):
    print(f'Task {number} started: ')
    for i in range(length):
        print(f'Task {number} prints {i}')

t1 = threading.Thread(target=task, args=(0, 1000))
t2 = threading.Thread(target=task, args=(1, 1000))
t3 = threading.Thread(target=task, args=(2, 1000))
t4 = threading.Thread(target=task, args=(4, 1000))

t1.start()
t2.start()
t3.start()
t4.start()

t1.join()
t2.join()
t3.join()
t4.join()

print('All done')

এই কোডটি রান করলে দেখা যাবে ১০০০ পর্যন্ত প্রিন্ট ঠিকই হচ্ছে কিন্তু ফাংশন নাম্বার একের পর এক না হয়ে একেক অর্ডারে হচ্ছে।

থ্রেড ব্যবহার করে ৪ বার ১০০০ পর্যন্ত প্রিন্ট করা
থ্রেড ব্যবহার করে ৪ বার ১০০০ পর্যন্ত প্রিন্ট করা

এর কারন হচ্ছে এখন আর ফাংশন কল গুলো একটা শেষ হওয়ার পরে আরেকটি হচ্ছে না। বরং একই সাথে ৪টি ভিন্ন থ্রেডে কাজ করছে। এখন আমরা কোডটি একটু ভেঙে দেখি। প্রথমেই আমরা পাইথনের threading লাইব্রেরি import করব। আমাদের task() ফাংশন আগের মতই থাকবে। এর পরে আমরা ৪টি থ্রেড তৈরি করব। threading.Thread() ক্লাস আমাদেরকে থ্রেড তৈরি করে দিবে। এর জন্য একে একটি টার্গেট ফাংশন (যেই ফাংশনটি রান করতে হবে) এবং সেই টার্গেট ফাংশনের আর্গুমেন্ট গুলো (iterable) হিসাবে args= এর ভ্যালু হিসেবে দিতে হবে।

আমাদের task() function টি দুটি প্যারামিটার নিয়ে থাকে number যা দিয়ে আমরা কত নম্বর থ্রেড এখন কাজ করছে বুঝতে পারব এবং length যা হচ্ছে ফাংশনটি কত পর্যন্ত প্রিন্ট করবে তা।

এর পরেই আমরা আমাদের ৪টি থ্রেড এর এক্সিকিউশন শুরু করে দিব Thread.start() ফাংশনটি কল করার মাধ্যমে। এর পরে আমরা Thread.join() ফাংশন কল করার মাধ্যমে থ্রেড গুলোকে প্রোগ্রাম এর মেইন থ্রেডের সাথে যোগ করে দিচ্ছি।

Thread.join() প্রকৃতপক্ষে অনেক গুরত্বপূর্ন একটি কাজ করছে। এটি বোঝা অত্যন্ত জরুরী। আমরা যখনই একটি নতুন থ্রেড তৈরি করছি সেটি আমাদের প্রোগ্রাম বা সোজা করে বললে আমাদের স্ক্রিপ্টটি যেই থ্রেডে চলছে তা থেকে ভিন্ন নতুন একটি থ্রেডে ফাংশনটি রান করছে। অর্থাৎ আমাদের স্ক্রিপ্টের থ্রেড শেষ হওয়ার সাথে সেই থ্রেড শেষ হওয়ার সম্পর্ক নেই। তার মানে দাঁড়াচ্ছে আমাদের স্ক্রিপ্টটি সেই ফাংশনের থ্রেড গুলো শেষ হওয়ার আগেই পরবর্তি কাজে চলে যাবে।

বিষয়টি বোঝার জন্য আমরা যদি উপরের প্রোগ্রামটির tx.join() ফাংশনগুলো তুলে নেই তাহলে দেখতে পারব যে সব কাজ শেষ করার আগেই সে print('All done') লাইনটি এক্সিকিউট করছে। কিন্তু tx.join() থাকলে সবগুলো থ্রেড শেষ হবার পরই কেবন সে লাইনটি এক্সিকিউট করছে। এর মানে দাঁড়াচ্ছে Thread.join() ফাংশনটি মেইন থ্রেড তথা আমাদের স্ক্রিপ্ট যেই থ্রেডে রান হচ্ছে তার প্রবাহ ব্যহত করছে।

StackOverFlow এর এই উত্তরটি থেকে আমরা Thread.join() এর ভূমিকা বুঝতে পারি। ASCII তে বানানো ডায়াগ্রামটি যথেষ্ট ভালো তাই অমি আর অন্য কোন ছবি দিলাম না।

without join:
+---+---+------------------                     main-thread
    |   |
    |   +...........                            child-thread(short)
    +..................................         child-thread(long)

with join
+---+---+------------------***********+###      main-thread
    |   |                             |
    |   +...........join()            |         child-thread(short)
    +......................join()......         child-thread(long)

with join and daemon thread
+-+--+---+------------------***********+###     parent-thread
  |  |   |                             |
  |  |   +...........join()            |        child-thread(short)
  |  +......................join()......        child-thread(long)
  +,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,     child-thread(long + daemonized)

'-' main-thread/parent-thread/main-program execution
'.' child-thread execution
'#' optional parent-thread execution after join()-blocked parent-thread could 
    continue
'*' main-thread 'sleeping' in join-method, waiting for child-thread to finish
',' daemonized thread - 'ignores' lifetime of other threads;
    terminates when main-programs exits; is normally meant for 
    join-independent tasks

Credit

কেন করব?

একাধিক কাজ একই সময়ে করার জন্য

আধুনিক CPU এর কাজ ক্ষমতা অনেক বেশি যার অনেকখানিই আমরা ব্যবহার করতে পারি না। যেমন এই ব্লগ লেখার সময়ই আমার নিজের কম্পিউটারের ব্যবহার যদি দেখি তাহলে দেখতে পারি যে মাত্র ৪% CPU ব্যবহৃত হচ্ছে।

CPU ব্যবহার
CPU ব্যবহার

বিশ্বাস করুন খুব একটা কম কাজ করছে না এখনো আমার কম্পিউটার। প্রায় ১৫টা Chrome ট্যাব, ডাউনলোড ম্যানেজার, এই লেখার text editor টি, সাথে আরো কিছু সফটওয়্যার চলছে। তাতেও CPU এর প্রায় ৯৫% ক্ষমতাই অব্যবহৃত রয়ে যাচ্ছে। এখন আমরা যদি কোনো লম্বা প্রোগ্রামও রান করি সেটি যদি মাত্র একটি থ্রেড ব্যবহার করে তাহলে CPU এর খুব বেশি কর্মক্ষমতা সে ব্যবহার করতে পারবে না। এর জন্য বড় বড় সব প্রোগ্রাম অনেকগুলো থ্রেড এক সাথে ব্যবহার করতে থাকে। তাতে করে সেগুলো এক সাথে অনেক কাজ করতে সক্ষম হয়।

কম সময়ে রান করার জন্য

এটি বোঝার জন্য আমরা খুব সহজ একটি কাজ করতে পারি। সেটি হচ্ছে ছোট একটি প্রোগ্রাম লেখে ফেলা।

from time import time, sleep

def work(no):
    print(f'Started worker # {no}')
    sleep(1)
    print(f'Worker # {no} done')

start_time = time()
for i in range(4):
    work(i)
print(f'Total time needed: {int(time() - start_time)}s')

স্বভাবতই এই প্রোগ্রামটি রান করতে ২০ সেকেন্ড সময় লাগবে। প্রতিটি ফাংশন কলে প্রোগ্রামটি ৫ সেকেন্ড করে সময় নিবে। এখন একে যদি আমরা ৪টি ভিন্ন থ্রেডে রান করি তাহলে কি দাঁড়ায় দেখা যাক -

from time import time, sleep
import threading

def work(no):
    print(f'Started worker # {no}')
    sleep(5)
    print(f'Worker # {no} done')

start_time = time()

t1 = threading.Thread(target=work, args=[0])
t2 = threading.Thread(target=work, args=[1])
t3 = threading.Thread(target=work, args=[2])
t4 = threading.Thread(target=work, args=[4])

t1.start()
t2.start()
t3.start()
t4.start()

t1.join()
t2.join()
t3.join()
t4.join()

print(f'Total time needed: {int(time() - start_time)}s')

এবং কা-বুম। মাত্র পাঁচ সেকেন্ডেই চারটি ফাংশনের কাজই শেষ। এখন এখানে sleep() ব্যবহার করা হয়েছে প্রতিকী সময়ক্ষেপণ দেখানোর জন্য। ব্যবহারিক ক্ষেত্রে এর ভূমিকা অপরিসিম।

একটা ছোট উদাহরণ দেই। আমরা কম বেশি সবাই তো MS Word এর সাথে পরিচিত। Word এ কোন ডকুমেন্ট টাইপ করার সময় অনেকেই খেয়াল করে থাকবে যে টাইপের সাথে সাথেই টাইপ করা শব্দটির বানান ঠিক আছে কিনা, সম্পূর্ণ বাক্যটির ব্যাকরণ ঠিক আছে কিনা, এসব যাচাই করে ফেলছে WORD। এসকল কাজ করতে হয়ত খুব বেশি সময় লাগে না। কিন্তু ভাবা যায় যে প্রতিটি key চাপার সাথে সাথে এই কাজগুলো যদি একটি মাত্র থ্রেডেই সে করত তাহলে প্রতি কি-প্রেস এর সাথে একটি বেশি latency (সময়ক্ষেপণ) যুক্ত হত। যা এর ব্যবহার অনেক বেশি ব্যহত করত।

কখন ব্যবহার করব

যেখানেই একাধিক কাজ থাকবে সেখানেই কি একাধিক থ্রেড ব্যবহার করব? না। থ্রেড ব্যবহার করা মোটেই ছেলেখেলা নয়। অনেক সফটওয়্যার ব্যবহারের অযোগ্য হয়ে পরে থ্রেড এর বেঠিক ব্যবহারের জন্য। কারন হিসেবে আমরা ছোট একটি প্রোগ্রামের সাহায্য নিব -

import threading

valueList = [1, 2, 3]
currentValueIndex = 0

def update():
    global valueList, currentValueIndex    
    with open('test.txt', 'w') as file:
        newValue = str(valueList[currentValueIndex])
        file.write(newValue)
        print(f'Current file value written: {newValue}')
        currentValueIndex += 1
        if currentValueIndex == 3:
            currentValueIndex = 0
    
def readFile():
    with open('test.txt', 'r') as file:
        print(f'Read value from file: {file.read()}')

threads = []

for i in range(2):
    threads.append(threading.Thread(target=update))
    threads.append(threading.Thread(target=readFile))

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

কোডটি আগে ভালোভাবে বোঝার চেষ্টা করা যাক। এখানে দুটি worker ফাংশন আছে -

  • update(): এর কাজ মূলত হচ্ছে গ্লোবাল লিস্ট valueList থেকে একটি ভ্যালু নিয়ে test.txt নামক একটি ফাইলে সেভ করা ।
  • readFile(): এবং এর কাজ হচ্ছে test.txt ফাইলে বর্তমানে থাকা ভ্যালুটি প্রিন্ট করা।

খুবই সোজা সাপ্টা বিষয়। তাহলে এবার আমরা এদেরকে ২ বার করে রান করতে চাই। তাহলে কি হবার কথা?? দেখা যাক হিসাব কি বলে -

  • update() প্রথম কলঃ test.txt ফাইলে valueList থেকে currentIndex = 0 অর্থাৎ 1 নিয়ে ফাইলে সেভ করবে এবং currentIndex এর ভ্যালু ১ বাড়িয়ে 0 থেকে 1 করে দিবে।
  • readFile() প্রথম কলঃ test.txt ফাইলে থাকা 1 ভ্যালুটি পড়ে নিয়ে টার্মিনালে প্রিন্ট করবে।
  • update() দ্বিতীয় কলঃ test.txt ফাইলে valueList থেকে currentIndex = 1 অর্থাৎ 2 নিয়ে ফাইলে সেভ করবে এবং currentIndex এর ভ্যালু ১ বাড়িয়ে 1 থেকে 2 করে দিবে।
  • readFile() দ্বিতীয় কলঃ test.txt ফাইলে থাকা 2 ভ্যালুটি পড়ে নিয়ে টার্মিনালে প্রিন্ট করবে।

দেখাই যাক এবার রান করে। কি ভেলকি দেখা যায় -

থ্রেড ব্যবহার করে ফাইলে ইনপুট আউটপুট
থ্রেড ব্যবহার করে ফাইলে ইনপুট আউটপুট

কি... কি... কিন্তু?? ঘটনা কি ঘটল তাহলে?? আশা করি অনেকেই ধরে ফেলেছেন। না পারলে ব্যপারটা ব্যাখ্যা করা যাক। প্রথমেই আমরা এইটুক অন্তত বুঝতে পারছি যে এখন আর একটি শেষ হবার পরে আরেকটি ফাংশন কল হচ্ছে এমন কিছু ঘটছে না। যার কারনে প্রথমে update() ফাংশন কল হয়ে শেষ হওয়ার আগেই readFile() ফাংশন কল হয়ে যাওয়ার কারনে সে test.txt ফাইলে কোন ভ্যালু পাচ্ছে না এবং কিছু প্রিন্টও করছে না। আবার দ্বিতীয় ক্ষেত্রেও update() ফাংশন ফাইলের ভ্যালু আপডেট করার আগেই সে আগে থাকা ভ্যালু 1 পড়ে নিচ্ছে।

এখানে বলাই বাহুল্য যে সবার ক্ষেত্রে এরকমটিই ঘটবে তার কোন নিশ্চয়তা নেই। কারো কারো ক্ষেত্রে সঠিক ঘটনাই ঘটতে পারে। সেক্ষেত্রে কয়েকবার রান করার পর এরকম বা আরো উদ্ভট কিছু পাওয়াও সম্ভব।

এখন তাহলে আলোচনায় ফেরত যাই। এ ঘটনা থেকে আমরা কি বুঝতে পারলাম? থ্রেড ব্যবহারের সময় resource (file, data, variables) কিভাবে ব্যবহৃত হচ্ছে, কোনোটি কয়েক যায়গায় একই সাথে ব্যবহার হচ্ছে কিনা হলে কিভাবে হচ্ছে তা খেয়াল রাখা অত্যন্ত জরুরী। পাইথনের কম্পাইলারে তাই Global Interpreter Lock (GIL) নামক এক বস্তু আছে। এটি মূলত ভিন্ন ভিন্ন থ্রেডের মধ্যকার resource sharing রোধ করে। এ নিয়ে অন্য কোনদিন বিস্তারিত আলোচনা করা যাবে। তাহলে বোঝাই যাচ্ছে মাল্টিথ্রেডিং এর সুবিধা থাকলেও সাথে থাকে এর জটিলতাও। মাল্টিথ্রেডিং এর জটিলতা প্রোগ্রামের জটিলতার সাথে সাথে বহ্যগুনে বৃদ্ধি পায়।

প্রতিদিনের প্রোগ্রামিং এর কাজে কি ব্যবহার করতে পারব?

সেটি আসলে নির্ভর করবে প্রোগ্রামার এর কাজের ধরণের উপর ভিত্তি করে। যেমন game development এ মাল্টিথ্রেডিং একটি অত্যন্ত গুরত্বপূর্ন বিষয়। গেম এর বিভিন্ন দিক থাকে যা একটি মাত্র থ্রেড ব্যবহার করে করা কোন ভাবেই সম্ভব নয়। আবার ছোটখাটো প্রোগ্রামের কোন একটি অংশের জন্য হয়ত একাধিক থ্রেড প্রয়োজন হতে পারে যেমন মিউজিক প্ল্যেয়ার, বা ডাউনলোড ম্যানেজার। এসব প্রোগ্রামের সব ক্ষেত্রে আবার মাল্টিথ্রেডিং এর প্রয়োজন নেই।

ডাটা সায়েন্সের জন্য মাল্টিথ্রেডিং অত্যন্ত গুরত্বপূর্ন কেননা CPU এবং GPU (Graphics processing unit) এর সর্বাধিক ব্যবহারের জন্য মাল্টিথ্রেডিং এর বিকল্প নেই। যদিও GPU তে মাল্টিথ্রেডিং (CUDA) বিষয়টি ভিন্ন ভাবে ব্যাখ্যা করা হয়।

থ্রেডিং এবং মাল্টিথ্রেডিং এর আলোচনা আজকের জন্য এতটুকুই থাক। পরবর্তিতে আমরা মাল্টিপ্রসেসিং নিয়ে জানব এবং মাল্টিথ্রেডিং এর ব্যবহার দেখব।

আরও জানার জন্য

যেকোনো জিজ্ঞাসা বা পরামর্শের জন্যঃ [email protected]