Maison > Article > développement back-end > Résoudre intelligemment le problème de blocage multithread de Python
[Recommandations d'apprentissage associées : vidéo python]
Aujourd'hui, c'est le 25ème article du sujet Python Parlons du problème de blocage dans le développement multi-thread.
Le principe de l'impasse est très simple , peut être décrit en une phrase. Autrement dit, lorsque plusieurs threads accèdent à plusieurs verrous, différents verrous sont détenus par différents threads , et ils attendent tous que d'autres threads libèrent les verrous, ils tombent donc dans une attente permanente. Par exemple, le thread A détient le verrou numéro 1 et attend le verrou numéro 2, et le thread B détient le verrou numéro 2 et attend le verrou numéro 1. Ensuite, ils n'attendront jamais le jour de l'exécution. Cette situation est appelée une impasse.
Il existe un problème célèbre concernant l'impasse appelé le problème du Dîner des philosophes. Il y a 5 philosophes assis ensemble, et chacun d'eux doit prendre deux fourchettes avant de pouvoir manger. S'ils récupèrent leur fourche de gauche en même temps, ils attendront éternellement que la fourche de droite soit libérée. Cela conduira à une attente éternelle et ces philosophes mourront de faim.
Il s'agit d'un modèle très vivant, car dans les scénarios de concurrence informatique, le nombre de certaines ressources est souvent limité. Il est très probable que plusieurs threads préempteront. S'ils ne sont pas bien gérés, tout le monde obtiendra une ressource puis attendra une autre ressource.
Il existe de nombreuses solutions au problème des blocages. Nous en présentons ici une plus simple, qui consiste à numéroter ces verrous. Nous stipulons que lorsqu'un thread doit détenir plusieurs verrous en même temps, doit accéder à ces verrous par ordre croissant de numéros de série . Nous pouvons facilement y parvenir grâce aux gestionnaires de contexte.
Tout d'abord, introduisons brièvement la gestion du contexte. En fait , nous utilisons souvent des gestionnaires de contexte. Par exemple, l'instruction with que nous utilisons souvent est une utilisation classique des gestionnaires de contexte. Lorsque nous ouvrons un fichier via l'instruction with, elle gérera automatiquement la fermeture du fichier après lecture et la gestion des exceptions levées, ce qui peut nous faire économiser beaucoup de code.
De même, nous pouvons également définir nous-mêmes un processeur de contexte. C'est en fait très simple. Il nous suffit d'implémenter les deux fonctions __enter__ et __exit__. La fonction __enter__ est utilisée pour implémenter les opérations et le traitement avant d'entrer dans la ressource, donc évidemment la fonction __exit__ correspond à la logique de traitement une fois l'utilisation de la ressource terminée ou une exception se produit. Avec ces deux fonctions, nous disposons de notre propre classe de traitement de contexte.
Regardons un exemple :
class Sample: def __enter__(self): print('enter resources') return self def __exit__(self, exc_type, exc_val, exc_tb): print('exit') # print(exc_type) # print(exc_val) # print(exc_tb) def doSomething(self): a = 1/1 return adef getSample(): return Sample()if __name__ == '__main__': with getSample() as sample: print('do something') sample.doSomething()复制代码
Lorsque nous exécutons ce code, les résultats imprimés à l'écran sont conformes à nos attentes.
Si nous regardons la fonction __exit__, nous constaterons qu'elle a 4 paramètres, et les trois paramètres après correspondent c'est le cas où une exception est levée. type correspond au type d'exception, val correspond à la valeur de sortie lorsque l'exception se produit et trace correspond à la pile en cours d'exécution lorsque l'exception est levée. Ces informations sont souvent utilisées lorsque nous dépannons des exceptions. Grâce à ces trois champs, nous pouvons personnaliser le traitement des exceptions possibles en fonction de nos besoins.
L'implémentation d'un gestionnaire de contexte ne doit pas nécessairement être implémentée via une classe. Python fournit également des annotations de gestion de contexte. En utilisant des annotations, nous pouvons facilement implémenter la gestion de contexte. Regardons également un exemple :
import timefrom contextlib import contextmanager@contextmanagerdef timethis(label): start = time.time() try: yield finally: end = time.time() print('{}: {}'.format(label, end - start)) with timethis('timer'): pass复制代码
Dans cette méthode, la partie avant rendement est équivalente à la fonction __enter__, et la partie après rendement est équivalente à __exit__. Si une exception se produit, elle sera levée dans l'instruction try, nous pourrons alors écrire except pour gérer l'exception.
了解了上下文管理器之后,我们要做的就是在lock的外面包装一层,使得我们在获取和释放锁的时候可以根据我们的需要,对锁进行排序,按照升序的顺序进行持有。
这段代码源于Python的著名进阶书籍《Python cookbook》,非常经典:
from contextlib import contextmanager# 用来存储local的数据_local = threading.local()@contextmanagerdef acquire(*locks): # 对锁按照id进行排序 locks = sorted(locks, key=lambda x: id(x)) # 如果已经持有锁当中的序号有比当前更大的,说明策略失败 acquired = getattr(_local,'acquired',[]) if acquired and max(id(lock) for lock in acquired) >= id(locks[0]): raise RuntimeError('Lock Order Violation') # 获取所有锁 acquired.extend(locks) _local.acquired = acquired try: for lock in locks: lock.acquire() yield finally: # 倒叙释放 for lock in reversed(locks): lock.release() del acquired[-len(locks):]复制代码
这段代码写得非常漂亮,可读性很高,逻辑我们都应该能看懂,但是有一个小问题是这里用到了threading.local这个组件。
它是一个多线程场景当中的共享变量,虽然说是共享的,但是对于每个线程来说读取到的值都是独立的。听起来有些难以理解,其实我们可以将它理解成一个dict,dict的key是每一个线程的id,value是一个存储数据的dict。每个线程在访问local变量的时候,都相当于先通过线程id获取了一个独立的dict,再对这个dict进行的操作。
看起来我们在使用的时候直接使用了_local,这是因为通过线程id先进行查询的步骤在其中封装了。不明就里的话可能会觉得有些难以理解。
我们再来看下这个acquire的使用:
x_lock = threading.Lock()y_lock = threading.Lock()def thread_1(): while True: with acquire(x_lock, y_lock): print('Thread-1')def thread_2(): while True: with acquire(y_lock, x_lock): print('Thread-2')t1 = threading.Thread(target=thread_1)t1.start()t2 = threading.Thread(target=thread_2)t2.start()复制代码
运行一下会发现没有出现死锁的情况,但如果我们把代码稍加调整,写成这样,那么就会触发异常了。
def thread_1(): while True: with acquire(x_lock): with acquire(y_lock): print('Thread-1')def thread_2(): while True: with acquire(y_lock): with acquire(x_lock): print('Thread-1')复制代码
因为我们把锁写成了层次结构,这样就没办法进行排序保证持有的有序性了,那么就会触发我们代码当中定义的异常。
最后我们再来看下哲学家就餐问题,通过我们自己实现的acquire函数我们可以非常方便地解决他们死锁吃不了饭的问题。
import threadingdef philosopher(left, right): while True: with acquire(left,right): print(threading.currentThread(), 'eating')# 叉子的数量NSTICKS = 5chopsticks = [threading.Lock() for n in range(NSTICKS)]for n in range(NSTICKS): t = threading.Thread(target=philosopher, args=(chopsticks[n],chopsticks[(n+1) % NSTICKS])) t.start()复制代码
关于死锁的问题,对锁进行排序只是其中的一种解决方案,除此之外还有很多解决死锁的模型。比如我们可以让线程在尝试持有新的锁失败的时候主动放弃所有目前已经持有的锁,比如我们可以设置机制检测死锁的发生并对其进行处理等等。发散出去其实有很多种方法,这些方法起作用的原理各不相同,其中涉及大量操作系统的基础概念和知识,感兴趣的同学可以深入研究一下这个部分,一定会对操作系统以及锁的使用有一个深刻的认识。
相关学习推荐:编程视频
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!