[整理] python多线程

最近在进阶多进程爬虫,所以就先整理一些线程相关知识概念,不然哪天我又忘了就不好了。

这里简单说明一下线程和进程的关系

  • 线程是进程的组成部分,一个进程可以拥有多个线程。
  • 一个线程必须有一个父进程。

线程可以拥有自己的堆栈、程序计数器,但不拥有系统资源。它与父进程的其他线程拥有该进程的所有资源。不过这里会存在阻塞问题,线程是独立运行的 ,它不知道进程中是否还有其他线程存在,线程是抢占式运行,也就是说当前运行的线程随时会被挂起,或者其他没抢到的线程就在等待,形成资源阻塞问题。

碎碎念

不过在python中,多线程不大实用(小声

因为有GIL的存在,python的多线程不能真正利用到多核。GIL 在任意指定的时刻只允许单线程执行,也就是说每个CPU同一时间只能执行一个线程。可以说python中的多线程是带着头套的假多线程。

GIL

GIL全称是Global Interpreter Lock,中文叫全局解释器锁。

线程执行方式

获取GIL执行(直到sleep或者被挂起)→释放GIL

因此,线程想要执行就得先拿到GIL。一个python进程中只有一个GIL。线程既然拿不到GIL那就不能进入CPU执行。

小拓展

多核多线程比单核多线程执行效率还要差噢

在单核多线程里面,一个线程执行结束,释放出GIL,那么被唤醒的另一个线程就能拿到GIL,能够无缝衔接的执行。

但是在多核多线程里面,CPU1里的线程释放GIL后,其他CPU上的线程就会开始竞争,但是不巧的话,GIL可能又会被CPU1再次拿到,这样导致其他CPU上被唤醒的线程会在调度和等待上来回切换。

(题外话)如果想python充分利用多核CPU,应该选择用多进程

因为每一个进程都会有各自独立的GIL,互不干扰。

所以接下来写的东西,就当作是一个知识概念去理解就好了!

多线程原理

  • 同一时间,CPU只能处理1个线程(即只有1个线程工作)
  • 多线程并发(同时)执行,其实是CPU快速的在多线程之间的调度(切换)
  • 如果CPU调度线程的时间足够快,就造成了线程并发的假象

创建和执行一个线程

一般使用以下两种方法

实例化Thread类

Thread类定义:

threading.Thread(self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None)

  • group: 用于扩展
  • target:可调用的对象,在线程启动后执行
  • name:线程的名称
  • args和kwargs分别表示调用target时的参数列表和关键字参数

例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import threading
import time

def test(arg):
    time.sleep(1)
    print('线程 '+str(arg)+" 在跑")

if __name__ == '__main__':
    for i in range(3):
        t = threading.Thread(target=test, args=(i,))
        t.start() #启动线程

继承Thread类

这一种方法是继承Thread类,然后重写它的run方法。直接看代码应该能看得懂的了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import threading

class MyThread(threading.Thread):
    def __init__(self, thread_name):
        # 调用父类的初始化函数。
        threading.Thread.__init__(self)
	
    #重写
    def run(self):
        print("%s正在运行中......" % self.name)

if __name__ == '__main__':    
    for i in range(10):
        MyThread("线程-" + str(i)).start()

主线程和子线程关系

线程是程序执行的最小单位,所以当一个进程启动的时候,会默认产生一个主进程。

主线程执行完自己任务后,子进程会继续执行直到自己任务结束

  • setDaemon(False)

设定多线程时,主线程会创造多个子线程,python里默认情况就是setDaemon(False)

例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import time
import threading

def doWaiting():
    print('子线程开始执行时间:', time.strftime('%H:%M:%S'))
    time.sleep(3)
    print('子线程执行结束时间:', time.strftime('%H:%M:%S'))
    

t = threading.Thread(target=doWaiting)
t.start()
# 确保线程t已经启动
time.sleep(1)

print('主线程结束')

执行结果

1
2
3
子线程开始执行时间: 17:45:32
主线程结束
子线程执行结束时间: 17:45:35

主线程一旦执行结束,全部子进程被强制终止运行

也就是说,主线程执行结束,子进程还没来得及执行就被强制终止了

  • setDaemon(True)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import time
import threading

def doWaiting():
    print('子线程开始执行时间:', time.strftime('%H:%M:%S'))
    time.sleep(3)
    print('子线程执行结束时间:', time.strftime('%H:%M:%S'))
    

t = threading.Thread(target=doWaiting)
t.setDaemon(True)
t.start()
# 确保线程t已经启动
time.sleep(1)

print('主线程结束')

执行结果

1
2
子线程开始执行时间: 17:44:05
主线程结束

主进程任务结束后,进入阻塞状态,直到等所有子线程结束后,主线程再终止

也可以理解为同步结束。这里用到一个方法join,它所要完成的工作就是让线程同步

例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import time
import threading

def Waiting():
    print('子线程开始执行时间:', time.strftime('%H:%M:%S'))
    time.sleep(3)
    print('子线程执行结束时间:', time.strftime('%H:%M:%S'))
    
t = threading.Thread(target=Waiting)
t.start()
# 确保线程t已经启动
time.sleep(1)
print('主线程开始等子线执行完毕')
# 将一直堵塞,直到t运行结束。
t.join()
print('等到了,主线程也结束')

这里讲讲join(timeout):

假如t.join(timeout=2),意思是主线程堵塞2s后将会执行结束,子线程继续执行

如果不设置timeout参数,那么就是等子线程都结束了,主线程再结束

执行结果

1
2
3
4
子线程开始执行时间: 17:39:57
主线程开始等子线执行完毕
子线程执行结束时间: 17:40:00
等到了,主线程也结束

一些锁

互斥锁

互斥锁:一种简单的加锁方法来控制线程对共享资源的访问

为什么要加锁

在同时运行的多个任务,可能都需要使用同一种资源,这样会使当前运行的任务出现混乱。举个简单的例子,比如在公司,只有一台打印机,当我在使用打印机打印东西的时候(我还没打完),另一个同事又恰好发动了打印机的请求,那么打印机同时打印我俩的东西,这样打印出来的东西就很乱,最后还要人工出来分哪些是我打印的,哪些是那个同事打印的。还有在学校打印店里也有这种情况,打印论文的时候简直痛苦面具。。。

解决上述存在的问题,加锁就是能够保证共享资源在任意的时间里,只有一个线程访问。此时资源被上锁,只有当该线程执行完毕,释放锁(开锁),其他进程才能有机会使用这个资源。

乐观锁

对操作数据时非常乐观,假设数据一般不会造成冲突(被修改数据),所以不会上锁。它太乐观,持有十分信任别人的态度。只是在执行更新的时候才去检测在这期间数据是否被修改。出问题(出现冲突/数据被修改)了,就回滚和提示(返回用户错误的信息,让用户决定怎么做)。

乐观锁适合用于读操作多写操作少的场景,冲突几率小

悲观锁

互斥锁、自旋锁、读写锁都属于悲观锁

对操作数据时非常悲观,总是假设最坏的情况,认为每次取数据的时候都会被其他线程修改数据,所以加锁。一旦加了锁,不同线程同时执行时,只能有一个线程执行,其他线程就得等待,直到那个线程释放锁。

这里容易产生阻塞问题,所以吞吐量不高,悲观锁适合用于读操作少,写操作多的应用

死锁

死锁就是由两个或以上的线程互相持有对方需要的资源,且都不释放占有的资源,导致这些线程都处于等待状态。

死锁产生原因

  • 不同线程同时占用资源并上锁
  • 这些线程都需要对方的资源,但是都被上锁了,拿不到
  • 这些线程都不释放自己拥有的资源

多线程间的资源竞争

就像上面所说的,python并不是多个任务同步执行,而是给每一个任务分配执行时间,轮着来执行,因此就会存在资源竞争问题。

下面给出一个简单的例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import threading
import time

def threadA(count):
    global num
    print('线程A当前数值是:'+str(num))
    for i in range(count):
        num += 1
    print('线程A最终数值是'+str(num))


def threadB(count):
    global num
    print('线程B当前数值是:'+str(num))
    for i in range(count):
        num += 1
    print('线程B最终数值是'+str(num))


def main():
    t1 = threading.Thread(target=threadA, args=(1000000,))
    t2 = threading.Thread(target=threadB, args=(1000000,))
    t1.start()
    t2.start()
    time.sleep(1)
    print('最终num数值是'+str(num))


num = 0
lock = threading.Lock()

if __name__ == '__main__':
    main()

执行结果

1
2
3
4
5
线程A当前数值是:0
线程B当前数值是:0
线程A最终数值是1000000
线程B最终数值是1235050
最终num数值是1235050

可以看出最终的数值num不是2000000,却是1235050(这个数值每次执行都不一样,不固定的)

因为这个是多线程,cpu在处理这两个线程的时候,可能线程A还没来得及将num值+1,就开始处理线程B,那又有可能线程B还没进行num+1,又切换到处理线程A。导致最终的结果不是预想的那样。

解决方法

为了保护线程的正确执行,需要对线程进行加锁。通过调用threading中Lock类的acquire()方法,将线程所执行的代码保护起来,其他线程在该线程没用释放锁的时候,只能处于监听状态

下面是加了锁的例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import threading
import time

def threadA(count):
    global num
    print('线程A当前数值是:'+str(num))
    for i in range(count):
        lock.acquire()
        num += 1
        lock.release()
    print('线程A最终数值是'+str(num))


def threadB(count):
    global num
    print('线程B当前数值是:'+str(num))
    for i in range(count):
        lock.acquire()
        num += 1
        lock.release()
    print('线程B最终数值是'+str(num))


def main():
    t1 = threading.Thread(target=threadA, args=(1000000,))
    t2 = threading.Thread(target=threadB, args=(1000000,))
    t1.start()
    t2.start()
    time.sleep(1)
    print('最终num数值是'+str(num))


num = 0
lock = threading.Lock()

if __name__ == '__main__':
    main()

执行结果

1
2
3
4
5
线程A当前数值是:0
线程B当前数值是:0
线程A最终数值是1691989
线程B最终数值是2000000
最终num数值是2000000

尾声

目前就整理到这里,如果以后有补充就继续更新!