最近在进阶多进程爬虫,所以就先整理一些线程相关知识概念,不然哪天我又忘了就不好了。
这里简单说明一下线程和进程的关系
- 线程是进程的组成部分,一个进程可以拥有多个线程。
- 一个线程必须有一个父进程。
线程可以拥有自己的堆栈、程序计数器,但不拥有系统资源。它与父进程的其他线程拥有该进程的所有资源。不过这里会存在阻塞问题,线程是独立运行的 ,它不知道进程中是否还有其他线程存在,线程是抢占式运行,也就是说当前运行的线程随时会被挂起,或者其他没抢到的线程就在等待,形成资源阻塞问题。
碎碎念
不过在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()
|
主线程和子线程关系
线程是程序执行的最小单位,所以当一个进程启动的时候,会默认产生一个主进程。
主线程执行完自己任务后,子进程会继续执行直到自己任务结束
设定多线程时,主线程会创造多个子线程,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
|
主线程一旦执行结束,全部子进程被强制终止运行
也就是说,主线程执行结束,子进程还没来得及执行就被强制终止了
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
|
尾声
目前就整理到这里,如果以后有补充就继续更新!