346 total views
Anders Fongen, mars 2023
Dette innlegget handler om programmering med flere tråder i mikrokontrollere som bruker MicroPython. Det vil studere hvordan MicroPython kjører tråder i ESP32 og Raspberry Pico, og gi forslag til hvordan programmene kan gjøres sikre og raske.
Innledning
Noen programmer blir enklere å skrive dersom aktivitetene kan deles opp i uavhengige, samtidige aktiviteter. F.eks. kan et program trenge å reagere på trafikk fra nettverket samtidig som det skal betjene en brukerdialog. Alle moderne operativsystemer tillater at en prosess har flere slike utføringsaktiviteter som kalles tråder. Tråder skal derimot ofte samarbeide om deling og kommunikasjon av data, noe som krever at de benytter seg av synkroniseringsmekanismer. Dette temaet hører til fagfeltet Operativsystemer, og kan ikke diskuteres grundig i dette innlegget. Se kapitlene 6 og 7 i boka som jeg har skrevet om operativsyster. Den finner du her.
“Vanlig” Python har et omfattende programbibliotek for programmering med flere tråder, (en veiledning i bruken finnes f.eks. her). MicroPython, derimot, har et lite og enkelt bibliotek som tilbys på noen microkontrollere, bl.a. ESP32 og Raspberry Pico (ikke ESP8266). Derfor skal resten av innlegget studere forskjeller mellom disse to produktene, og foreslå programmeringsteknikker for programmer som vil kjøre riktig på begge to.
Merk at vi nå diskuterer hvordan tråder utføres i et MicroPython program. Med andre programmeringsspråk kan situasjonen være anderledes. Merk også at innlegget ikke beskriver hvordan PC og mikrokontroller settes opp for programutvikling. En veiledning for dette finner du f.eks. her (ESP32) og her (Raspberry Pico).
Bildet under viser hvordan de to mikrokontrollerne ser ut, og viser også hvorfor jeg foretrekker å bruke Raspberry Pico: ESP32 er noe bredere, og hindrer tilkoplingsledninger på den ene siden når den settes i eksperimentbrett (breadboard) som vist på bildet.
Trådbehandling og CPU-kjerner
ESP32 og Raspberry Pico (heretter kun kalt Pico) har begge 32-bits registre i CPU (noe som gjør regneoperasjoner raskere) og to CPU-kjerner. Med flere CPU-kjerner kan samtidige oppgaver kjøres i separate CPU-kjerner og dermed utføres raskere enn om oppgavene utføres skiftevis på samme CPU-kjerne.
- Pico kan utføre oppgaver i to tråder, som utføres i hver sin CPU-kjerne.
- ESP32 kan utføre oppgaver i mange tråder (flere enn to), men alle kjører skiftevis på samme CPU-kjerne.
Hvordan kan vi fastslå dette? Se på programkoden nedenunder. Der kjører en oppgave i “hovedtråden”, og den samme oppgaven utføres i en “undertråd”. De representerer den samme arbeidsmengde og koden er laget kun for at det skal ta litt tid.
1 import _thread, time
2 def subthread():
3 for i in range(4000000):
4 x = 232**10
5 print("Undertråd ferdig")
6
7 # Hovedtråd
8 x1 = time.time()
9 _thread.start_new_thread(subthread,())
10 for i in range(4000000):
11 x = 232**10
12 print("Hovedtråd ferdig")
13 x2 = time.time()
14 print("Kjøretid:", (x2-x1), "sekunder")
Dersom vi setter et kommentartegn ved linje 9 vil det ikke startes en undertråd, og vi ønsker å se om dette har noen effekt på den totale kjøretiden:
- Dersom kjøretiden ikke endres, tyder det på at undertråden er blitt utført i en egen CPU-kjerne, som nå ikke lenger blir brukt.
- Dersom kjøretiden (nær) halveres, tyder det på at begge trådene er blitt utført i samme CPU-kjerne, og at arbeidsmengden for denne nå er blitt halvert.
CPU | med undertråd | uten undertråd |
Pico | 30 sekunder | 29 sekunder |
ESP32 | 37 sekunder | 22 sekunder |
Ut fra disse målingene konkluderer vi med at ESP32 kun benytter én CPU-kjerne, mens Pico benytter begge to.
Videre skal vi bruke det samme programmet for å studere antall tråder som kan kjøres på de to mikrokontrollerne. Ved å duplisere linje 9 slik at de skapes 2 undertråder, vil ESP32 utføre programmet korrekt (men med lengre kjøretid). Pico derimot, vil avbryte programmet med feilmeldingen “OSError: core1 in use“. Et program som skal være portabelt mellom Pico og ESP32 må derfor ikke lage mer enn én undertråd. Derimot vil Pico tilby raskere utføring av et slikt program, fordi begge CPU-kjernene tas i bruk.
Synkronisering av tråder
I mange tilfeller vil trådene i et program samarbeide gjennom delte variabler, og de trenger å varsle hverandre om hendelser, f.eks. om en ny oppgave skal påbegynnes eller en annen er avsluttet og resultatet er klart. Tråder som ikke synkroniseres riktig kan skape såkalt Race Condition, som er en feilkilde som kan være vanskelig å spore opp fordi den ikke skaper feil hver gang. Man unngår Race Condition ved å reservere en ressurs (f.eks. ved endring av en variabel verdi) med en mekanisme som heter Lock (andre ganger kalt Mutex).
Tenk på Lock som en nøkkel du trenger for å låse opp en dodør. Når det er ledig på do henger nøkkelen på en krok utenfor døren. Du ønsker trolig å sitte i fred på do, og tar med deg nøkkelen inn dit. Etter bruk låser du døren og henger nøkkelen tilbake på kroken. Om ikke nøkkelen henger på kroken betyr det at doen er opptatt, og du venter utenfor til nøkkelen igjen dukker opp. Doen kan på denne måten bare brukes av én person, og om det er ledig går du inn uten venting.
Programlistingen under er eksempel på en Race Condition: To tråder tester og modifiserer den felles variabelen x, og dersom begge trådene finner at x==0, og deretter utfører x=x+1, vil verdien av x bli 2, selv om programkoden tilsynelatende ikke lar det skje. Feilen skyldes altså at “test-and-set” sekvensen (linje 10-13 og 20-23) ikke er atomisk, dvs. at andre tråder kan bruke den samme variabelen underveis i operasjonen.
For at programmet skal utføres riktig må vi låse test-and-set sekvensen, slik som vist i linje 9 og 19, og etterpå låse opp slik som vist i linje 14 og 24. Om du vil kjøre dette programmet på en mikrokontroller for å forvisse deg om dette, fjern kommentartegnet på disse linjene og kontrollere resultatet (som er fravær av feilmeldingen “Race Condition error…”).
1 # Race condition demo
2 import _thread, time
3 x=0 # Delt variabel
4 slock = _thread.allocate_lock() # Lås
5 def subthread():
6 global x, slock
7 while True:
8 # Lås for å fohinde race condition
9 # slock.acquire()
10 if x==0:
11 x=x+1
12 elif x==1:
13 x=x-1
14 # slock.release() # Lås opp
15 # Hovedtråd
16 _thread.start_new_thread(subthread,())
17 while True:
18 # Lås for å fohinde race condition
19 # slock.acquire()
20 if x==0:
21 x=x+1
22 elif x==1:
23 x=x-1
24 # slock.release()
25 if x<0 or x>1:
26 print("Race condition error, x=%d" % x)
27 break
Kommunikasjon mellom trådene
Tråder som løper uavhengig i et program gjør det oftest for å løse et problem, og må derfor kommunisere, ikke bare beskyttes mot Race Condition slik som vist ovenfor, én tråd skal f.eks. hente data fra en nettradio, og en annen tråd skal dekode mp3 og sende lyddata til en høyttaler via en DAC. I et slikt program må det foregå en synkronisert datastrøm mellom to tråder, som skal ha følgende egenskaper:
- Mottakertråden skal dekode dataene slik de mottas, og vente (blokkeres) i de periodene det ikke mottas data.
- Sendertråden skal sende data når det er mulig, og dersom lageret som brukes til overføringen går fullt, skal sendertråden vente på at mottakertråden leser data og frigjør plass.
- Data skal overføres First-in-First-out, dvs. mottas i samme rekkefølge som de sendes.
Det er mange programtyper som kan ha nytte av å la trådene inngå et såkalt produsent-konsument samarbeid, så her kommer noen klasser i MicroPython som fungerer som ønsket på både Pico og ESP32.
Condition klasse
Denne klassen har en oppførsel omtrent som en Lock, men kan “huske” et antall release-kall slik at mange acquire-kall kan slippe gjennom uten å blokkeres. Objekter av denne klassen kan faktisk erstatte Locks, men kan også brukes til mer. Her er programkoden:
import _thread
class Condition:
def __init__(self,initvalue=0):
self.fulfilled = initvalue
self.mutex = _thread.allocate_lock()
self.sync = _thread.allocate_lock()
self.sync.acquire()
def wait(self):
self.mutex.acquire()
self.fulfilled -= 1
while self.fulfilled < 0:
self.mutex.release()
self.sync.acquire()
self.mutex.acquire()
self.mutex.release()
def notify(self):
self.mutex.acquire()
self.fulfilled += 1
if self.fulfilled <= 0:
self.sync.release()
self.mutex.release()
notify-funksjonen ligner på Locks release-funksjonen, men Condition-klassen kan lagre antallet notify-kall, mens release-funksjonen kan ikke kalles på en Lock som ikke er “låst” (med acquire). Legg merke til variabelen fulfilled som holder rede på dette antallet, og at kall til wait-funksjonen teller ned den verdien og fortsetter dersom fulfilled er større enn null. Dersom verdien er >0 betyr det at den har noen notify-kall “til gode”, om verdien er <0 viser det antall tråder som blokkeres av wait-kall.
Operasjonene på fulfilled må beskyttes av en Lock (kalt mutex) for å unngå Race Condition.
FIFO klasse
FIFO-klassen har et lagringsområde for data som er sendt, men ikke mottatt (i variabelen storage), og funksjoner for å sende (put(object)) og motta (get()). For å synkronisere senderen og mottageren over tilstanden til lagringsområdet, dvs. blokkere senderen når lageret er fullt og blokkere mottageren når lageret er tomt, brukes to Condition-variabler: dataElements og freeSpace. Studer selv hvordan de brukes i get- og put-metodene for å synkronisere tråder over en tilstand, ikke over hvilken programkode som utføres (slik en Lock vil gjøre).
Merk forøvrig at flere tråder kan sende data gjennom put-metoden, selv om det ikke er aktuelt med Pico, som bare kan kjøre to tråder. Setter man ESP32 opp med mer enn to tråder er programmet ikke lenger portabelt til Pico.
import _thread
class FIFO:
def __init__(self,capacity):
self.storage = [0 for i in range(capacity)]
self.CAP = capacity
self.dataElements = Condition(0)
self.freeSpace = Condition(capacity)
self.front = 0
self.back = 0
self.mutex = _thread.allocate_lock()
def _in(self,obj): # Put object at back of queue
with self.mutex:
self.storage[self.back] = obj
self.back = (self.back+1) % self.CAP
def _out(self): # Tak object from front of queue
with self.mutex:
obj = self.storage[self.front]
self.front = (self.front+1) % self.CAP
return obj
def put(self,obj): # Put object in FIFO, block if no space
self.freeSpace.wait()
self._in(obj)
self.dataElements.notify()
def get(self): # Get object from FIFO, block if no data
self.dataElements.wait()
obj = self._out()
self.freeSpace.notify()
return obj
Legg merke til at self.mutex.acquire og self.mutex.release er nå erstattet med with self.mutex. Bruk av with-statement gjør koden letter å lese, og er forklart f.eks. her.
Et testprogram for FIFO-klassen
Her viser jeg også et kort testprogram som illustrerer hvordan FIFO-klassen kan brukes av flere tråder. Programmet starter med at hovedtråden sender 5 tegnstrenger til FIFO-objektet før den blir blokkert, deretter skriver den tegnstrengene 5-10 i samme takt som undertråden (mottageren) leser strengene 1-5.
# Test-kode
import _thread,time
def subthread(fifo):
time.sleep(1)
while True:
o = fifo.get()
print(o)
time.sleep(0.5)
# Now test FIFO
fifo = FIFO(5)
_thread.start_new_thread(subthread,(fifo,))
for i in range(10):
str = "string nr %d"%i
fifo.put(str)
print("Fra hovedtråd: %s"%str)
Sluttord
De nye mikrokontrollerne med flere CPU-kjerner og som kan programmeres i MicroPython representerer en veldig interessant utvikling. Programmering av Internet-of-Things anvendelser trekker fordeler av et godt programmeringsspråk som utnytter maskinvareressursene godt.
Merk at både ESP32 og den versjonen av Pico som kalles Raspberry Pico W, også har en WiFi-modul som lar enheten kople seg til eksisterende trådløse nettverk, eller de kan sette opp sitt eget nettverk som andre maskiner kan kople seg til.
Og jeg håper at Condition- og FIFO-klassen som jeg har laget til dette innlegget kommer til nytte. Det er lettere å lage stabile og korrekte flertrådsprogrammer med hjelpeklasser som støtter en god programmeringsmodell, i dette tilfellet produsent-konsument-modellen.