python关于线程相关

本文详细介绍了计算机CPU的架构、多核与多线程的工作原理,以及超线程技术。探讨了CPU与IO密集型任务的区别,指出在IO密集型任务中多线程可以提高效率,而在CPU密集型任务中,Python的全局解释器锁(GIL)限制了多线程在多核CPU上的并行执行能力,导致性能下降。此外,还分析了Python中多线程的使用场景及性能影响,并给出了多线程代码示例。
摘要由CSDN通过智能技术生成

cpu计算密集型和io密集型**

一. 计算机的简介

计算机的中央处理器包含:运算器和控制器,统称为“中央处理器”,即为CP

img

存储器又分为:内存和外存;

cpu、存储器、输入设备、输出设备等接口均通过 "系统总线"连接在一起;总线粗略理解为“主板”;

cpu架构和工作原理

计算机有5大基本组成部分,运算器,控制器,存储器,输入和输出。运算器和控制器封装到一起,加上寄存器组和cpu内部总线构成中央处理器(CPU)。cpu的根本任务,就是执行指令,对计算机来说,都是0,1组成的序列,cpu从逻辑上可以划分为3个模块:控制单元、运算单元和存储单元。这三个部分由cpu总线连接起来。

img

CPU的运行原理就是:控制单元在时序脉冲的作用下,将指令计数器里所指向的指令地址(这个地址是在内存里的)送到地址总线上去,然后CPU将这个地址里的指令读到指令寄存器进行译码。对于执行指令过程中所需要用到的数据,会将数据地址也送到地址总线,然后CPU把数据读到CPU的内部存储单元(就是内部寄存器)暂存起来,最后命令运算单元对数据进行处理加工。周而复始,一直这样执行下去。

多核cpu和多cpu

多个物理CPU,CPU通过总线进行通信,效率比较低。
img

多核CPU,不同的核通过L2 cache进行通信,存储和外设通过总线与CPU通信

img

相比于多个处理器而言,多核处理器把多个CPU(核心)集成到单个集成电路芯片(integrated circuit chip)中,因此主板的单个socket也可以适应这样的CPU,不需要去更更改一些硬件结构。一个双核的CPU有2个中央处理单元,因此不像上面我介绍的hyper-threading技术那样,操作系统看到的只是一种假象,这回操作系统看到的是真正的2个核心,所以2个不同的进程可以分别在不同的核心中同时执行,这大大加快了系统的速度。由于2个核心都在一个芯片上,因此它们之间的通信也要更快,系统也会有更小地延迟。

下图展示了一个Intel Core i7处理器的一个组织结构,这个微处理器芯片中有4个CPU核,每个核中都有它自己的L1和L2缓存。

img

超线程技术相关知识

Hyperthreading 有时叫做 simultaneous multi-threading,它可以使我们的单核CPU执行多个控制流程。这个技术会涉及到备份一些CPU硬件的一些信息,比如程序计数器和寄存器文件等,而对于比如执行浮点运算的单元它只有一个备份,可以被共享。一个传统的处理器在线程之间切换大约需要20000时钟周期,而一个具有Hyperthreading技术的处理器只需要1个时钟周期,因此这大大减小了线程之间切换的成本。hyperthreading技术的关键点就是:当我们在处理器中执行代码时,很多时候处理器并不会使用到全部的计算能力,部分计算能力会处于空闲状态,而hyperthreading技术会更大程度地“压榨”处理器。举个例子,如果一个线程必须要等到一些数据加载到缓存中以后才能继续执行,此时CPU可以切换到另一个线程去执行,而不用去处于空闲状态,等待当前线程的IO执行完毕。

1. 什么是超线程(hyper-threading)?

​ **超线程(hyper-threading)其实就是同时多线程(simultaneous multi-theading),是一项允许一个CPU执行多个控制流的技术。**它的原理很简单,就是把一颗CPU当成两颗来用,将一颗具有超线程功能的物理CPU变成两颗逻辑CPU,而逻辑CPU对操作系统来说,跟物理CPU并没有什么区别。因此,操作系统会把工作线程分派给这两颗(逻辑)CPU上去执行,让(多个或单个)应用程序的多个线程,能够同时在同一颗CPU上被执行。注意:两颗逻辑CPU共享单颗物理CPU的所有执行资源。因此,我们可以认为,超线程技术就是对CPU的虚拟化

2. 超线程技术的由来

​ 超线程技术是同时多线程技术的一种实现形式,由Intel公司提出,而该技术背后的概念则是Sun公司的专利。Sun公司虽然倒下了,但它永远是一个伟大的公司。

​ 纵观计算机的历史,有两个需求是驱动计算机科技进步的持续动力。

​ 第一,人类想让计算机做得更多;

​ 第二,人类想让计算机跑得更快;

3. 单线程v.s.超线程

img

​ 常规的CPU需要大约两万个时钟周期做不同线程间的切换,而超线程的CPU可以在单个时钟周期的基础上决定要执行哪一个线程。这使得CPU能够更好地利用它的处理资源。例如:假设一个线程必须等到某些数据被装入到cache中,那么CPU就可以继续去执行另一个线程。

cpu的缓存

CPU缓存是位于CPU与内存之间的临时数据交换器,它的容量比内存小的多但是交换速度却比内存要快得多。CPU缓存一般直接跟CPU芯片集成或位于主板总线互连的独立芯片上。

img

随着多核CPU的发展,CPU缓存通常分成了三个级别:L1L2L3。级别越小越接近CPU,所以速度也更快,同时也代表着容量越小。L1 是最接近CPU的, 它容量最小(例如:32K),速度最快,每个核上都有一个 L1 缓存,L1 缓存每个核上其实有两个 L1 缓存, 一个用于存数据的 L1d Cache(Data Cache),一个用于存指令的 L1i Cache(Instruction Cache)。L2 缓存 更大一些(例如:256K),速度要慢一些, 一般情况下每个核上都有一个独立的L2 缓存; L3 缓存是三级缓存中最大的一级(例如3MB),同时也是最慢的一级, 在同一个CPU插槽之间的核共享一个 L3 缓存。

读取数据过程。就像数据库缓存一样,首先在最快的缓存中找数据,如果缓存没有命中(Cache miss) 则往下一级找, 直到三级缓存都找不到时,向内存要数据。一次次地未命中,代表取数据消耗的时间越长。

计算过程。程序以及数据被加载到主内存;指令和数据被加载到CPU的高速缓;CPU执行指令,把结果写到高速缓存;高速缓存中的数据写回主内存

进程和线程

进程

进程是程序的一次执行过程,是一个动态概念,是程序在执行过程中分配和管理资源的基本单位,

线程

线程是CPU调度和分派的基本单位,它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

  • 联系

线程是进程的一部分,一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。

  • 区别:理解它们的差别,从资源使用的角度出发。(所谓的资源就是计算机里的中央处理器,内存,文件,网络等等)

根本区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位

在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。

所处环境:在操作系统中能同时运行多个进程(程序);而在同一个进程(程序)中有多个线程同时执行(通过CPU调度,在每个时间片中只有一个线程执行)

内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源。

包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻量级进程。

进程和线程在多核cpu,多cpu中的运行关系

操作系统会拆分CPU为一段段时间的运行片,轮流分配给不同的程序。对于多cpu,多个进程可以并行在多个cpu中计算,当然也会存在进程切换;对于单cpu,多个进程在这个单cpu中是并发运行,根据时间片读取上下文+执行程序+保存上下文。同一个进程同一时间段只能在一个cpu中运行,如果进程数小于cpu数,那么未使用的cpu将会空闲。

多线程的概念主要有两种:一种是用户态多线程;一种是内核态多线程,对于内核态多线程(java1.2之后用内核级线程),在操作系统内核的支持下可以在多核下并行运行;对于多核cpu,进程中的多线程并行执行。对于单核cpu,多线程在单cpu中并发执行,根据时间片切换线程。同一个线程同一时间段只能在一个cpu内核中运行,如果线程数小于cpu内核数,那么将有多余的内核空闲。

线程状态转换

img

线程状态从大的方面来说,可归结为:初始状态、可运行状态、不可运行状态和消亡状态,具体可细分为上图所示7个状态,说明如下:

1)线程的实现有两种方式,一是继承Thread类,二是实现Runnable接口,但不管怎样,当我们new了Thread实例后,线程就进入了初始状态;

2)当该对象调用了start()方法,就进入可运行状态;

3)进入可运行状态后,当该对象被操作系统选中,获得CPU时间片就会进入运行状态;

4)进入运行状态后涉及的情况就比较多,大致有如下情形: ﹒run()方法或main()方法结束后,线程就进入终止状态; 当线程调用了自身的sleep()方法或其他线程的join()方法,就会进入阻塞状态(该状态虽停止当前线程,但并不释放所占有的资源)。当sleep()结束或join()结束后,该线程进入可运行状态,继续等待OS分配时间片; 当线程刚进入可运行状态(注意,还没运行),发现将要调用的资源被锁住(synchroniza,lock),将会立即进入锁池状态,等待获取锁标记(这时的锁池里也许已经有了其他线程在等待获取锁标记,这时它们处于队列状态,既先到先得),一旦线程获得锁标记后,就转入可运行状态,等待OS分配 CPU时间片; 当线程调用wait()方法后会进入等待队列(进入这个状态会释放所占有的所有资源,与阻塞状态不同),进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒(由于notify()只是唤醒一个线程,但我们由于不能确定具体唤醒的是哪一个线程,也许我们需要唤醒的线程不能够被唤醒,因此在实际使用时,一般都用notifyAll()方法,唤醒有所线程),线程被唤醒后会进入锁池,等待获取锁标记。 当线程调用stop方法,即可使线程进入消亡状态,但是由于stop方法是不安全的,不鼓励使用,大家可以通过run方法里的条件变通实现线程的 stop。

二.cpu、io密集型介绍

I/O(读写密集型) :指的是系统的cpu效能 比磁盘读写效能要高很多,这时是cpu在等io(磁盘到内存)的读写,此时cpu使用率不高;在服务器上进行网络通讯、网络传输、磁盘读写等均为IO操作,多为网络请求压力大、磁盘读写频繁的操作;io密集型的可能需要对磁盘进行升级、提高磁盘的相应速度和传输效率或通过负载技术将文件读写分散到多台服务器中;如果网络请求负载较高,可通过负载均衡技术、水平扩展提高负载。

CPU(计算密集型)指的是系统的磁盘读写效率高于cpu效率,此时cpu效率可能达到100%,但是磁盘读写速度很快;多用来做计算、逻辑判断等cpu操作。可考虑通过消息队列或其他降维算法,将计算分散到不同的计算节点

三.python的GIL锁

GIL是什么?

GIL的全称是Global Interpreter Lock(全局解释器锁),来源是python设计之初的考虑,为了数据安全所做的决定。

在Python多线程下,每个线程的执行方式:

1.获取GIL

2.执行代码直到sleep或者是python虚拟机将其挂起。

3.释放GIL

可见,某个线程想要执行,必须先拿到GIL,我们可以把GIL看作是“通行证”,并且在一个python进程中,GIL只有一个。拿不到通行证的线程,就不允许进入CPU执行。

python2.x和python3.x的GIL区别

在python2.x里,GIL的释放逻辑是当前线程遇见IO操作或者ticks计数达到100(ticks可以看作是python自身的一个计数器,专门做用于GIL,每次释放后归零,这个计数可以通过 sys.setcheckinterval 来调整),进行释放。

而在python3.x中,GIL不使用ticks计数,改为使用计时器(执行时间达到阈值后,当前线程释放GIL),这样对CPU密集型程序更加友好,但依然没有解决GIL导致的同一时间只能执行一个线程的问题,所以效率依然不尽如人意。

python多线程对于不同任务线程的处理能力

1、CPU密集型代码(各种循环处理、计数等等),在这种情况下,ticks计数很快就会达到阈值,然后触发GIL的释放与再竞争(多个线程来回切换当然是需要消耗资源的),所以python下的多线程对CPU密集型代码并不友好。

2、IO密集型代码(文件处理、网络爬虫等),多线程能够有效提升效率(单线程下有IO操作会进行IO等待,造成不必要的时间浪费,而开启多线程能在线程A等待时,自动切换到线程B,可以不浪费CPU的资源,从而能提升程序执行效率)。所以python的多线程对IO密集型代码比较友好。

线程颠簸(thrashing)

多核多线程比单核多线程更差,原因是单核下多线程,每次释放GIL,唤醒的那个线程都能获取到GIL锁,所以能够无缝执行,但多核下,CPU0释放GIL后,其他CPU上的线程都会进行竞争,但GIL可能会马上又被CPU0拿到,导致其他几个CPU上被唤醒后的线程会醒着等待到切换时间后又进入待调度状态,这样会造成线程颠簸(thrashing),导致效率更低,我们用以下伪代码进行解释。

while True:
    acquire GIL
    for i in 1000:
        do something
    release GIL
    /* Give Operating System a chance to do thread scheduling */

以上代码为python多线程GIL的伪代码。我们可以看出,这种模式在只有一个CPU核心的情况下毫无问题。任何一个线程被唤起时都能成功获得到GIL(因为只有释放了GIL才会引发线程调度)。但当CPU有多个核心的时候,问题就来了。从伪代码可以看到,从release GIL到acquire GIL之间几乎是没有间隙的。所以当其他在其他核心上的线程被唤醒时,大部分情况下主线程已经又再一次获取到GIL了。这个时候被唤醒执行的线程只能白白的浪费CPU时间,看着另一个线程拿着GIL欢快的执行着。然后达到切换时间后进入待调度状态,再被唤醒,再等待,以此往复恶性循环。

GIL对于多线程的影响

为了直观的理解GIL对于多线程带来的性能影响,这里直接借用的一张测试结果图(见下图)。图中表示的是两个线程在双核CPU上得执行情况。两个线程均为CPU密集型运算线程。绿色部分表示该线程在运行,且在执行有用的计算,红色部分为线程被调度唤醒,但是无法获取GIL导致无法进行有效运算等待的时间。

img

由图可见,GIL的存在导致多线程无法很好的立即多核CPU的并发处理能力。

那么Python的IO密集型线程能否从多线程中受益呢?我们来看下面这张测试结果。颜色代表的含义和上图一致。白色部分表示IO线程处于等待。可见,当IO线程收到数据包引起终端切换后,仍然由于一个CPU密集型线程的存在,导致无法获取GIL锁,从而进行无尽的循环等待。
img

简单的总结下就是:Python的多线程在多核CPU上,只对于IO密集型计算产生正面效果;而当有至少有一个CPU密集型线程存在,那么多线程效率会由于GIL而大幅下降。

从代码角度比较python多线程和单线程在CPU密集型任务中的性能表现

顺序执行的单线程(single_thread.py)

#! /usr/bin/python
 
from threading import Thread
import time
 
def my_counter():
    i = 0
    for _ in range(100000000):
        i = i + 1
    return True
 
def main():
    thread_array = {}
    start_time = time.time()
    for tid in range(2):
        t = Thread(target=my_counter)
        t.start()
        t.join()
    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))
 
if __name__ == '__main__':
    main()

同时执行的两个并发线程(multi_thread.py)

#! /usr/bin/python
 
from threading import Thread
import time
 
def my_counter():
    i = 0
    for _ in range(100000000):
        i = i + 1
    return True
 
def main():
    thread_array = {}
    start_time = time.time()
    for tid in range(2):
        t = Thread(target=my_counter)
        t.start()
        thread_array[tid] = t
    for i in range(2):
        thread_array[i].join()
    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))
 
if __name__ == '__main__':
    main()

我们的实验是在mac四核i5-10代的cpu环境下做的。img

可以看到python在多线程的情况下居然比单线程整整慢了45%。

python多线程api详解

start: 用start方法来启动线程,真正实现了多线程运行,这时无需等待run方法体代码执行完毕而直接继续执行下面的代码。通过调用Thread类的start()方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行run()方法,这里方法 run()称为线程体,它包含了要执行的这个线程的内容,Run方法运行结束,此线程随即终止。

run: run()方法只是类的一个普通方法而已,如果直接调用Run方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待run方法体执行完毕后才可继续执行下面的代码,这样就没有达到写线程的目的。总结:调用start方法方可启动线程,而run方法只是thread的一个普通方法调用,还是在主线程里执行。这两个方法应该都比较熟悉,把需要并行处理的代码放在run()方法中,start()方法启动线程将自动调用 run()方法,这是由jvm的内存机制规定的。并且run()方法必须是public访问权限,返回值类型为void。

join:在很多情况下,主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候就要用到join()方法了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值