Python: skriptování ve více vláknech

23. 3. 2009
Doba čtení: 7 minut

Sdílet

Ilustrační obrázek
Autor: Depositphotos – stori
Ilustrační obrázek
Nedávno jsem pracoval na skriptu, u kterého se rozdělení na vlákna přímo nabízelo. Skript byl psán v Pythonu, a tak jsem se rozhodl podívat, jak na tom je s multithreadingem. Ukážeme si, jaké možnosti má v této oblasti Python a jaké nástroje můžeme využít pro přístup ke společným prostředkům.

Proč

Dovolím si tvrdit, že víc jak polovina dnes prodaných procesorů určených pro notebooky nebo desktop má dvě a více jader. Tento trend s jistotou pokračuje dál a věřím že za pár let budou běžné procesory s mnohem větším počtem jader. V době multimédií, kdy máme na každé stránce několik flashových animací, v pozadí nám hraje hudba, systém si indexuje disk, různý záškodnický software v pozadí spamuje svět a k tomu ještě potřebujeme třeba i pracovat, potřebujeme procesor, který dokáže vykonávat několik úloh najednou.

Linux je na tuto dobu velmi dobře připraven a pokud stejně dobře budou pracovat i uživatelské aplikace, budeme při práci s naším systémem mnohem více spokojení. Bohužel právě na vývojáře aplikací jsou tím kladeny větší nároky. Již nestačí přemýšlet nad tím, jak se změní paměť po vykonání nějaké funkce, ale jak se možná změní paměť po vykonání této funkce předtím, případně po té, než naběhne nějaké vlákno.

I u jednojádrových procesorů mají vlákna smysl. Občas musí program čekat třeba na disk nebo odpověď nějaké vzdálené služby. Během čekání zatím může běžet jiné vlákno nebo rovnou proces.

Procesy vs. vlákna

Než se pustíme do vytvoření prvního vlákna, řekneme si, jak to funguje v Linuxu a víceméně i v jiných operačních systémech. Dřív nebo později narazíme v našem systému na něco, čemu se říká proces. Ten by se dal popsat jako balík, který obsahuje zásobník instrukcí, paměť, nějaké souborové descriptory, samotný kód atd. Výpis procesů a vláken získáme v Linuxu například spuštěním „ps aux“. Co jeden spuštěný program, to minimálně jeden proces. Jednotlivé procesy si jádro přepíná podle priority, kterou si buď určí samo nebo ji určí uživatel. Proces se může dále dělit na tzv. thready neboli vlákna. V Linuxu jsou implementované jako odlehčené procesy. Se svými rodičovskými procesy prakticky všechno až na zásobník a kód. Linux se staví ke každému vláknu jakoby to byl proces. Neexistuje tedy žádná nadřazenost procesů vůči vláknům při vykonávání programu.

V Pythonu najdeme vlákna v několika implementacích. Řekneme si o dvou z nich. Jedna je podobná vláknům v javě, kdy je každé z nich samostatný objekt odvozený od třídy Thread. Druhou možností je funkce start_new_thread(). Pomocí ní můžeme kteroukoli funkce spustit jako vlákno.

Komunikace mezi vlákny se nazývá synchronizace. V Pythonu nám pomohou objekty Event, RLock, Lock nebo Semaphore.

Zámky

Tato stručná teorie by nám měla stačit. Možná se teď ptáte, co se děje při přístupu ke sdílené pamětí, pokud se dvě vlákna rozhodnou zapsat na stejné místo paměti. Když se tak stane, hodnota která vznikne je s velkou pravděpodobností nepoužitelná. Konkrétně na tuto situaci se používá tzv. Mutex. Jedná se o zámek, který zamkne nějakou část kódu zatímco ji vykonává jedno vlákno a ostatní čekají. Této části kódu se říká kritická sekce. V C je to třeba implementované tak, že se na začátek kritické sekce nasadí funkce s ukazatelem na Mutex. Pokud je již nějaké vlákno v kritické sekci, tak vlákno čeká na uvolnění právě na této funkci. V Pythonu to je hodně podobné, pouze jsou zde Mutexy implementované jako objekty Lock a RLock.

Lock a RLock jsou více méně totožné, pouze ten druhý můžeme bezpečně použít několikrát.

Existuje ještě jedna možnost, jak zamezit vícenásobný přístup ke kritické sekci. Řeč je o semaforu. Nesnaží se ani o tak o ochranu paměti jako spíše prostředků. Například pokud si vytvoříme 100 vláken, které načtou data z databáze, pak něco spočítají a pak data zase zapíší. Databáze nám nepoděkuje, pokud ji zahltíme stovkou spojení a nejlíp ještě v několika procesech. Při vytvoření semaforu určíme, kolik vláken může do kritické sekce vstoupit. Implementace v Pythonu se chová tak, že si vytvoříme objekt Semaphore a dáme mu to správné číslo parametrem. Po zavolání zamykací metody se vnitřní čítač zmenší o jedničku. Po zavolání metody pro výstup z kritické sekce se čítač zase zvětší. Na začátku má čítač stejnou hodnotu jako předané číslo. Pokud je čítač na nule a nějaké vlákno se snaží o vstup, tak bude čekat, dokud z kritické sekce nevyleze vlákno jiné.

Události

Může se stát, že nám běží deset různých vláken vykonávajících nějakou úlohu. První pět má připravit data k použití a dalších pět ty data použije. Normálně bychom mohli nastartovat prvních pět, počkat až skončí, pak dalších pět. Došlo by však ke zbytečně prodlevě. I těch druhých pět vláken se musí připravit a musí vůbec vzniknout. Mnohem lepší způsob je nějaká možnost synchronizace práce mezi jednotlivými vlákny. V Pythonu k tomu máme objekt Event, ten si vytvoříme a pak předáme všem vláknům. Jedno vlákno z první pětice ho po vykonání nastaví na True a další vlákno, tentokrát z druhé pětice, na tuto událost čeká. Provádění tak může začít ihned po nastavení True. Druhá zmíněná možnost je nastartování vláken z druhé pětice po vykonání všech z první pětice. To můžeme udělat pomocí metody join(), která se chová podobně jako Event, ale je to metoda samotného objektu vlákna.

Příklady použití

Konec bylo teorie a přejdeme rovnou k ukázkám zmíněných mechanismů.

Funkce start_new_thread()

Začneme tou nejjednodušší ze zmíněných možností, start_new_thread(). Jak jsme si řekli, tato funkce spustí jinou funkci ve vlákně. Komunikace mezi procesem a vláknem je dost omezená:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time
from thread import start_new_thread

def go(p1,p2):
    print "Já jsem thread"
    print "\t Parm. 1:",p1,"Parm. 2:",p2

start_new_thread(go,("p11","p12"))
start_new_thread(go,("p21","p22"))

time.sleep(1) 

Když nepočkáme na ukončení běhu obou vláken, dostaneme s velkou pravděpodobností výjimku.

Modul threading

Mnohem užitečnější je modul threading. Jak jsme si řekli, zvenku je podobný vláknům z Javy. Začneme tím, že si ukážeme jak vytvořit a spustit vlákno:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys,os,time
from threading import Thread

class ct(Thread):
    def __init__(self,name,i):
        Thread.__init__(self)

        self.i = i
        self.name = name

    def run(self):
        for x in range(5):
            print self.name+":",self.i-x
            time.sleep(0.1)

t1 = ct("t1",5)
t2 = ct("t2",5)
t3 = ct("t3",5)
t1.start()
t2.start()
t3.start() 

Na výstupu se objeví zprávy ze všech tří vláken. Pořadí zpráv bude náhodné.

Lock (RLock)

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys,os,time
from threading import Thread,RLock

class ct(Thread):
    def __init__(self,name,i,l):
        Thread.__init__(self)

        self.i = i
        self.l = l
        self.name = name

    def run(self):
        self.l.acquire()
        for x in range(5):
            print self.name+":",self.i-x
            time.sleep(0.1)
        self.l.release()

l = RLock(False)

t1 = ct("t1",5,l)
t2 = ct("t2",5,l)
t3 = ct("t3",5,l)
t1.start()
t2.start()
t3.start() 

Díky zámku v tomto příkladu dostaneme zprávy postupně ze všech tří vláken. Nebudou se míchat jako v předchozím příkladě. Druhá možnost je použít metody Thread.join() v kořenovém procesu. Kód by mohl vypadat třeba takhle:

[...]
t1.start()
t2.join()
t2.start()
t2.join()
t3.start()
[...] 

Semaphore

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys,os,time
from threading import Thread,Semaphore

class ct(Thread):
    def __init__(self,name,i,s):
        Thread.__init__(self)

        self.i = i
        self.s = s
        self.name = name

    def run(self):
        self.s.acquire()
        for x in range(2):
            print self.name+":",self.i-x
            time.sleep(0.1)
        self.s.release()

s = Semaphore(2)

t1 = ct("t1",2,s)
t2 = ct("t2",2,s)
t3 = ct("t3",2,s)
t4 = ct("t4",2,s)
t5 = ct("t5",2,s)
t6 = ct("t6",2,s)
t1.start()
t2.start()
t3.start()
t4.start()
t5.start()
t6.start() 

Pomocí semaforů spustíme dvě vlákna najednou. Ve výsledku dostaneme smíchané zprávy vždy ze dvou vláken najednou.

bitcoin_smenarna

Event

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys,os,time
from threading import Thread,Event

class ct1(Thread):
    def __init__(self,name,i,e):
        Thread.__init__(self)

        self.i = i
        self.e = e
        self.name = name

    def run(self):
        print "Čekám na signál"
        e.wait()
        for x in range(3):
            print self.name+":",self.i-x
            time.sleep(0.1)

class ct2(Thread):
    def __init__(self,e):
        Thread.__init__(self)

        self.e = e

    def run(self):
        print "Čekáme"
        time.sleep(1)
        print "Odemknutí"
        self.e.set()


e = Event()

t1 = ct1("t1",3,e)
t2 = ct2(e)
t1.start()
t2.start() 

Poslední ukázka používá Event k předání signálu z jednoho vlákna do druhého. Nejdříve nastartujeme obě vlákna. Druhé vlákno se dostane k metodě wait() a čeká. První vlákno chvilku spí a po probuzení pošle zprávu pomocí set(). Hned poté začne druhé vlákno vykonávat svoji práci.

Závěr

Čím víc jader budou procesory mít, tím víc se bude na vlákna hledět. Pomalé procesory jako Intel Atom budou z více jader těžit asi nejvíce i u takovýchto malých skriptů. Tak jako tak, je dobré mít přehled o tom, co vlákna obnáší a co k nim při programování v Pythonu potřebujeme.

Odkazy

Autor článku

Adam Štrauch je redaktorem serveru Root.cz a svobodný software nasazuje jak na desktopech tak i na routerech a serverech. Ve svém volném čase se stará o komunitní síť, ve které je již přes 100 členů.