线程和进程(四)

Oyst3r 于 2023-01-12 发布

这篇文章本来想着是趁热打铁的带大家看一下线程池在 python 当中的应用,但还是稳一点,先去解决一些线程这块遗留的安全问题,这块的代码就先不接着那个爬取酷狗 TOP500 的脚本继续延伸了,从问题出发,我去编写了一些小脚本,去便于大家理解

线程安全概念的介绍

线程安全指的是某个函数,函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确的完成,但由于线程的执行随时会发生切换,就会造成了不可预料的结果,出现线程不安全,但是首先要明白一点,在 python 中的一些操作默认它就是线程安全的,而且 python 版本不一样,这些操作也会有所变化的

看这一张图片

现在的两个线程都是取 800 元,但如果线程 1 刚刚判断完余额是够 800 的,然后要操纵 I/O 流去给机器发指令啥的(咱们下面的具体代码用 time.sleep()去代替),那么此时就会发生线程的切换,但此时余额还没有更新,还是 1000,然后线程 2 也判断余额是够 800 的,那么就导致一下子取出 1600 元,银行就一下子亏 600,这显然是不符合逻辑的

这下子应该知道线程不安全是指的什么意思了吧

线程不安全代码的实现

银行的例子

接下来就写一个银行操作的小栗子去亲自看看这个过程是咋样的,废话不多说上代码

import threading


class Account:
    def __init__(self,balance):
        self.balance = balance


def draw(account, amount):
    if account.balance >= amount:
        print(threading.current_thread().name, '取钱成功')
        account.balance -= amount
        print(threading.current_thread().name,'余额',account.balance)

    else:
        print(threading.current_thread().name,'取钱失败,余额不足')

if __name__ == "__main__":
    account = Account(1000)
    thread_1 = threading.Thread(target=draw, args=(account, 800),name='threading_1')
    thread_2 = threading.Thread(target=draw, args=(account, 800),name='threading_2')

    thread_1.start()
    thread_2.start()

就按照上面图片这么写的话,就会出现下面的这两种情况

这个出现哪一种其实就是在看概率了,那如果就像刚刚辨析概念的时候所说的那样,用 time.sleep()去模拟那个操纵 I/O 的过程,那么结果又会怎么样呢,咱们稍微的改一下代码,看结果

import threading
import time
class Account:
    def __init__(self,balance):
        self.balance = balance


def draw(account, amount):
    if account.balance >= amount:
        time.sleep(0.1)
        print(threading.current_thread().name, '取钱成功')
        account.balance -= amount
        print(threading.current_thread().name,'余额',account.balance)

    else:
        print(threading.current_thread().name,'取钱失败,余额不足')

if __name__ == "__main__":
    account = Account(1000)
    thread_1 = threading.Thread(target=draw, args=(account, 800),name='threading_1')
    thread_2 = threading.Thread(target=draw, args=(account, 800),name='threading_2')

    thread_1.start()
    thread_2.start()

那么现在这种情况下,无论运行多少次,结果都是银行亏钱,这里要是还不懂,还请仔细阅读上面文章的内容,大白话就是钻了个空子

可能这个例子大家也没有刻意的去写过去想过,但如果你经常去操纵一些关于线程的代码,那么你对下面这个情况肯定不陌生,我们暂且称它为薛定谔的代码

计算 100 万的和

刚听到这个是不是也很诧异,因为这看上去明显就是一个需要用到 CPU 去计算的东西,是不是不会用到 I/O 流呢?显然这个想法有点太过于理想化了,举个比较形象的例子,这个操作往底层了说,还是要操纵寄存器对内存进行读写的,所以不可能不用到 I/O 流的!然后我想由这个例子还去引出一个叫做,内置线程安全的概念,一步一步说吧

首先,给出网上 CSDN 一位师傅写的代码(可以看出他曾经也曾困扰于这个问题),这里简单的引用一下(我的版本真的是没有哩,原因的话一会儿说)

然后下面是这段代码的运行结果

稍微解释一下,这么想,如果两个线程同时加了 1(相隔的时间很短,导致原来 num 还没来及的改变),是不是就会出现这种情况—加了两次但是 num 只增加了 1,所以把这些结果累加起来的话,结果就会小很多,但是如果循环的数字不是 5000000 次,而是一个比较小的数字,那么就不会出现这种情况,因为线程没切换它就已经加完了,下面是师傅设置累加数字小之后的测试结果

线程保护机制

相同的例子在我电脑上是看不到效果的,原因就是 python 官方对这种情况加了内置的线程保护,下面是我测试的代码

import threading
import time
import os

number = 0


def sum():

    global number

    for temp in range(5000000):
        number += 1

    print(number)


if __name__ == '__main__':
    print('main line')
    list = []
    for i in range(10):
        list.append(threading.Thread(target=sum)) # 创建进程

    for thread in list:
        thread.start()
    for thread in list:
        thread.join()

    print(number)

特点就是中间结果可能是乱的,但是结果总是对的,不管把 range(num)里面这个 num 设置的有多大,版本越高的 python 出现这种线程不安全的几率越小

类似的比如数组的.append 等等等操作在 python 里面都是默认安全的,这个想看有哪些自己去看 python 的开发文档,上面都写的很清楚

解决线程安全问题

这个想解决的话,目前就是加锁,但当我说完这个加锁的机制之后,你就会觉得,它好矛盾啊,本来就是想到去提高程序运行速度的一个产物,这么看来的话,要想不让数据有混乱,就又得加锁,然后速度就会下降,甚至还不如改成单线程的,所以大家在选择多线程开发的时候还是根据自身情况去选择

锁是保证线程安全的一种途径,你可以想象全局变量都存放在一个房间里,只有进入这个房间的人(线程)才能操作全局变量,在许多人进房间的时候,就可能出现混乱。因此他们约定,在门口挂一个牌子,一面写着有人,另一面写着没人,每当有人进出的时候就把牌子翻一面,别人看见这牌子是有人就在门口等着。(这就是锁的获取与释放)。然而既然是约定,就能被打破,有的人可能不知道这个约定,牌子上写着有人他也会进去。(这就是执行没有写锁部分的的方法的线程)

Python 的 threading 模块中有LockRLock两个类。他们都有这两个方法

Lock.acquire(blocking=True, timeout=-1) 获取锁。

Lock.release() 释放锁。

RLock的 R 表示 Reentrant,如果用 RLock,那么在同一个线程中可以对它多次 acquire,同时也要用相同数目的 release 来释放锁。这个东西的意义在于避免死锁

死锁(Deadlock)是指两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

举个例子,假如你要使用递归函数,这个递归函数中需要对某个全局变量修改,于是你加上了 Lock,然而在递归的过程中,第二层递归的 acquire 就获取不到锁了,于是第一层递归在等待第二层结束,而第二层在等待第一层的 release,这就造成了死锁。或者说一个程序要用到两把锁,然后两把锁都在等对方去释放,也会造成死锁。一般遇到这种情况就想想上篇文章说到的 Queue,因为它本身就是线程安全的

下面给个例子去解决这个问题,就拿银行来说吧,这个直观一点,下面给出解决的代码

import threading
import time

Lock = threading.Lock()

class Account:
    def __init__(self,balance):
        self.balance = balance


def draw(account, amount):

    with Lock:
        if account.balance >= amount:
            time.sleep(0.1)
            print(threading.current_thread().name, '取钱成功')
            account.balance -= amount
            print(threading.current_thread().name,'余额',account.balance)

        else:
            print(threading.current_thread().name,'取钱失败,余额不足')

if __name__ == "__main__":
    account = Account(1000)
    thread_1 = threading.Thread(target=draw, args=(account, 800),name='threading_1')
    thread_2 = threading.Thread(target=draw, args=(account, 800),name='threading_2')

    thread_1.start()
    thread_2.start()

这样的话,不管怎么调用,time.sleep()中的秒数设置的有多长,结果始终是对的,银行也是始终不会亏钱的

加锁对程序执行速度的影响

这里就直接给出测试的两个例子,把两个#号去掉就是加锁的意思,然后给大家看一下运行的结果

import threading
import time



#lock = threading.Lock()


def fuc_1():
    #with lock:
        time.sleep(0.1)


if __name__ == "__main__":
    a = time.time()
    thread_1 = threading.Thread(target=fuc_1, )
    thread_2 = threading.Thread(target=fuc_1, )
    thread_1.start()
    thread_2.start()
    thread_1.join()
    thread_2.join()
    b = time.time()
    print(b-a)

这个是不加锁的

这个是加锁的

它确实是会让程序的执行速度明显变慢的,所以说大家要根据自己开发工具的实际情况去选择,要是数据不要求那么准确(数据可以混乱一点),而且又追求速度的话,显然这个多线程是个不错的选择

误区:GIL 与 Lock 锁的区别

可能有很多小伙伴对这俩产生了误会,甚至认为这俩是一个东西

一句精辟的话总结:GIL 锁可以保证不管你的 CPU 是几核的,在同一时间内只能有一个线程在执行,但是不能保证线程共享资源的一个准确性,有的地方也叫它原子性,如果要想保证共享资源的准确性,就要用到 Lock 锁

这里再给一个关于银行的例子

然后上网看到一个师傅是这么写的,道理都一样,大家也可以看看这个,肯定都或多或少的对你有启发的

首先假设只有一个进程,这个进程中有两个线程 Thread1,Thread2, 要修改共享的数据date, 并且有互斥锁,执行以下步骤:

(1)多线程运行,假设Thread1获得GIL可以使用cpu,这时Thread1获得 互斥锁lock,Thread1可以改date数据(但并没有开始修改数据);

(2)Thread1线程在修改date数据前发生了 i/o操作 或者 ticks计数满100 (注意就是没有运行到修改data数据),这个时候 Thread1 让出了Gil,Gil锁可以被竞争;

(3) Thread1 和 Thread2 开始竞争 Gil (注意:如果Thread1是因为 i/o 阻塞 让出的Gil Thread2必定拿到Gil,如果Thread1是因为ticks计数满100让出Gil 这个时候 Thread1 和 Thread2 公平竞争);

(4)假设 Thread2正好获得了GIL, 运行代码去修改共享数据date,由于Thread1有互斥锁lock,所以Thread2无法更改共享数据date,这时Thread2让出Gil锁 , GIL锁再次发生竞争;

(5)假设Thread1又抢到GIL,由于其有互斥锁Lock所以其可以继续修改共享数据data,当Thread1修改完数据释放互斥锁lock,Thread2在获得GIL与lock后才可对data进行修改;

以上描述了 互斥锁和Gil锁的 一个关系

OKK 今天这篇文章写的有点手疼,累死了,希望大家都能理解哈!!!就到这里吧