Python 38 并发编程 多线程基础操作

作者 柚爸

线程

教学博客地址
线程后于进程出现,在线程出现之前,进程是最小资源和调度单位,在线程出现之后,实际上现代操作系统是多线程操作系统,CPU调度的最小单位,可执行的最小单元,都是线程了,而进程是分配资源的最小单元,是包含线程的数据结构.线程是动态的,在同一进程之内的所有线程,都可以共享该进程的内存和文件,包括主线程代码,主线程全局变量,打开的文件,信号量等组件.由于一个主线程的各个子线程都在同一个进程内,无需通过操作系统进行通信.
也就是说,从今天开始,针对可执行程序的概念需要全部更新,线程是最小的单元,进程是一堆线程的集合.说到执行了一个进程,实际上是执行了这个进程里的主线程(还可能有子线程).之前的多进程编程,实际上是为每个任务分配一个进程,而任务本身,是跑在每个进程的主线程内.
图解进程和线程

Threading模块的应用

Python内使用多线程,一般应用Threading模块,Threading模块的接口与multiprocessing模块的接口基本相同.先来看一个基本例子:

from threading import Thread
import time
import os


def func(n):
    global g
    time.sleep(1)
    print(os.getpid())
    print(n+g)

g = 10
for i in range(10):
    t = Thread(target=func, args=(i,))
    t.start()

从这个例子里可以看到,多线程编程在windows内无需再使用 if __name__ == “__main__”语句,线程并发执行,而且主线程定义的全局变量,是可以被子线程使用的.如果改用Process,会报错:name g is not defined.这就很明显的说明进程之间不能直接共享在主进程内的数据,而子线程和主线程都在一个进程内,可以直接共享定义好的全局变量.
实际上,支持各个线程运行的代码都在进程的内存内存储,各个线程存储各自的数据栈.上边代码里,g可以被共享,但是每个线程里内部的参数和其他形参是不能共享的.
此外,启动多线程还可以采用继承Thread类的方法,也需要实现.run方法,不再赘述.
在继续学习多线程之前,先要了解python的GIL机制.

全局解释器锁GIL

一句话解释GIL:Cpython解释器下的python程序,同一时间只能运行一个线程.
Python代码的执行由Python虚拟机(也叫解释器主循环)来控制。Python在设计之初就考虑到要在主循环中,同时只有一个线程在执行。虽然 Python 解释器中可以“运行”多个线程,但在任意时刻只有一个线程在解释器中运行。
对Python虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同一时刻只有一个线程在运行。在多线程环境中,Python 虚拟机按以下方式执行:
  a、设置 GIL;
  b、切换到一个线程去运行;
  c、运行指定数量的字节码指令或者线程主动让出控制(可以调用 time.sleep(0));
  d、把线程设置为睡眠状态;
  e、解锁 GIL;
  d、再次重复以上所有步骤。
在调用外部代码(如 C/C++扩展函数)的时候,GIL将会被锁定,直到这个函数结束为止(由于在这期间没有Python的字节码被运行,所以不会做线程切换)编写扩展的程序员可以主动解锁GIL。
GIL锁的不是数据,而是线程,也就是说,Cpython内部的解释器实际上只支持单线程工作,所以Cpython解释器的多线程效率并不高,无法充分利用CPU.
这个只是Cpython解释器的特性,并不是python语言的特性.如果用Jython等解释器,就没有GIL.当然,这个时候的进程内的数据安全,需要程序员额外关心.
Python cookbook 12.9 Python的全局锁问题 很好的解释了GIl的概念.
实际上,解释型语言到目前为止,由于并不是像编译型语言需要解释器环境,而是跑在虚拟机内,所以都不能很好的利用多核CPU.但是再次强调,这并非python语言的问题,而是解释器的问题.
解决多线程效率低下的问题,如果只工作在python环境下,可以采用multiprocessing的进程池模块,每个任务后台实际上分配了一个对应python解释器,也就是一个单独的进程(线程)来操作,可以实现真正的并行运行.另外的办法是采用C扩展,线程在遇到C扩展手工指令的时候,可以释放GIL,这个属于高端内容,以后再来学习.

Threading模块应用

对于外在表现来说,多线程和多进程对于无需共享数据的应用模式一样.

# 用多线程实现socketserver,原理一样,将接受的连接放入新线程内去执行,只需要修改一行代码
from socket import *
from threading import Thread

server = socket(AF_INET, SOCK_STREAM)
server.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
server.bind(('127.0.0.1', 8080))
server.listen(5)


def talk(conn, client_addr):
    print(conn, client_addr)
    while True:
        try:
            msg = conn.recv(1024)
            if not msg: break
            print('来自于{}的消息:{}'.format(client_addr,msg.decode('utf-8')))
            data = input('输入想发送给{}的信息:'.format(client_addr))  # 多线程里可以使用input,因为是在一个进程内,没有冲突
            conn.send(data.encode('utf-8'))
        except Exception:
            break
    conn.close()

while True:
    conn, client_addr = server.accept()
    p = Thread(target=talk, args=(conn, client_addr))
    p.start()

在进入线程各个组件之前,总结一下Thread模块的方法.

实例化 group=None, target=可调用对象, name=线程名, args=传给target的参数元组, kwargs=传给target的关键字参数字典, *, daemon=如果是None,继承当前线程的类型,如果是True表示是守护线程)
start() 启动线程
run() start方法就会调用run方法,如果继承Thread类,需要自己实现
join(timeout=None) 同步化等待主线程,和进程的join方法类似
name 返回线程名
is_alive() 线程是否还生存
daemon 设置是否为守护线程,跟随主线程结束而结束.主线程的结束和进程不同,是指所有子线程都执行完毕.
ident 最好是使用threading.get_ident()

此外还有threading模块的一些方法:
threading.currentThread(): 返回当前的线程变量。
threading.enumerate(): 返回一个包含正在运行的线程的list。正在运行指线程启动后、结束前,不包括启动前和终止后的线程。
threading.activeCount(): 返回正在运行的线程数量,与len(threading.enumerate())有相同的结果。

守护线程

守护线程和守护进程的定义类似,需要解决的是守护线程何时被结束.

from threading import Thread
import time

def func1():
    print('this is func1')
    time.sleep(10)

def func2():
    for i in range(5):
        print(i)
        time.sleep(1)

t1 = Thread(target=func1,args=())
t2 = Thread(target=func2,args=())
t1.start()
t2.start()

可以看到,在普通情况下,主线程一直到func1睡完了10秒才结束,5秒的时候子线程func2就结束了.现在把func1线程设置为守护线程.然后修改一下代码

from threading import Thread
import time

def func1():
    time.sleep(1)
    print('this is func1')

def func2():
    for i in range(5):
        print(i)
        time.sleep(1)


if __name__ == '__main__':
    t1 = Thread(target=func1,args=())
    t1.daemon = True
    t2 = Thread(target=func2,args=())
    t1.start()
    t2.start()

可以发现,func1的内容得到了运行,如果将Thread改成multiprocessing.Process,会发现没有显示出this is func1,说明func1进程已经结束了,这是因为守护线程和守护进程何时结束的判定是不同的.
对于线程来讲,主线程的结束,是指主线程内所有非守护进程统统运行完毕.守护线程会等待主线程的结束而结束.例子里,func1的关闭实际上是在func2的5秒计数结束之后.
而如果改成进程,主进程执行到t2.start()之后就意味着主进程结束,守护进程会立刻跟着结束.主进程执行这些代码用时很少,所以func1守护进程刚启动就被结束了,根本无法显示this is func1.
一句话总结就是:进程之间互相独立,运行完了该收就收,谁先结束就回收谁;线程之间都是在一个进程里,要死一起死,所以要等到最后的结束了,主线程才能结束守护线程.