python进程与线程(一)

本文介绍了Python中的进程和线程概念,详细讲解了多进程的multiprocessing模块,包括Process、进程池Pool、子进程和进程间通信。还探讨了多线程的threading模块,以及Lock的使用。最后提到了Python的GIL全局锁限制了多线程在多核CPU上的并行执行能力。
摘要由CSDN通过智能技术生成

一、进程和线程       

       对于操作系统来说,一个任务就是一个进程(Process),比如打开一个微信就启动了一个微信进程,打开一个淘宝就启动了一个淘宝进程,有些进程还不止同时干一件事,比如看视频进程,它可以同时运行视屏、音频、字幕等等事情。在一个进程内部,要同时干很多事情,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)

       由于每个进程至少要干一件事儿,所以,一个进程至少有一个线程。当然像看视屏这种复杂的进程可以有多个线程,多个线程可以同时执行,多线程的执行方式和多进程是一样的,也是由操作系统在多个线程之间快速切换,让每个线程都短暂地交替运行,看起来就像同时执行一样。当然,真正地同时执行多线程需要多核CPU才可能实现。

       如果我们想同时执行多个任务怎么办?有三种解决方案:

  •        第一种是启动多个进程,每个进程虽然只有一个线程,但是多个进程可以一块执行多个任务。(多进程模式)
  •        第二种是启动一个进程,在一个进程内启动多个线程,这样,多个线程也可以一起执行多个任务。(多线程模式)
  •        第三种是启动多个进程,每个进程再启动多个线程,这样同时执行更多的任务,但是这种模型较为复杂,采用较少。(多进程+多线程模式)

       总结:线程是最小的执行单元,而进程至少由一个线程组成。如何调度进程和线程,完全由操作系统决定,程序自己不能决定什么时候执行,执行多长时间。多进程和多线程涉及到同步、数据共享等问题,复杂度远远高于单进程单线程程序。

二、多进程(multiprocessing)

       Python的os模块封装了常见的系统调用,其中fork可以使一个进程在接到新任务时复制出一个子进程来处理新任务,常见的Apache服务器就是父进程监听端口,每当有新http请求时,就fork出子进程来处理新的http请求。

#fork()仅可作用于Linux/Mac系统
import os
print("Process (%s) start..."%os.getpid())
pid = os.fork()
#普通函数调用一次返回一次,但是fork()调用一次返回两次。因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),
#然后分别在父进程和子进程内返回。子进程永远返回0,而父进程返回子进程的ID。
if pid ==0:
    print("I am child process (%s) and my parent is %s." %(os.getpid(),os.getppid())) #getpid()当前进程,getppid()父进程
else:
    print("I (%s) just create a child process (%s)."%(os.getpid(),pid))
#结果:
Process (6136) start...
I (6136) just created a child process (6137).
I am child process (6137) and my parent is 6136.

       1.多进程multiprocessing 

        由于Windows没有fork调用,这时候就需要跨平台版本多进程模块multiprocessing,multiprocessing模块提供了一个Process类来代表一个进程对象,下面示例启动一个子进程并等待其结束:

from multiprocessing import Process
import os

#子进程要执行的代码
def run_proc(name):
    print("Run child process %s (%s)..."%(name,os.getpid()))

if __name__=="__main__":
    print("Parent process %s" %os.getpid())
    p = Process(target=run_proc,args=('test',))#创建子进程,只需要传入一个执行函数和函数的参数
    print("Child process will start")
    p.start() #启动子进程
    p.join() #等待子进程结束后再继续往下运行,通常用于进程间同步
    print("Child process end.")
#结果
Parent process 11844.
Child process will start.
Run child process test (1600)...
Child process end.

       2.进程池pool

       如果要启动大量的子进程,可以用进程池的方式批量创建子进程。

from multiprocessing import Pool
import os,time,random

def long_time_task(name):
    print("Run task %s (%s)..."%(name,os.getpid()))
    start = time.time()
    time.sleep(random.random()*3)
    end = time.time()
    print("Task %s runs %0.2f seconds." %(name,(end - start)))

if __name__ =="__main__":
    print("Parent process %s." %os.getpid())
    p = Pool(4)#最多同时执行4个进程
    for i in range(5):
        p.apply_async(long_time_task,args=(i,))
    print("Waiting for all subprocesses done...")
    p.close()
    p.join()#先调用close()后调用join()
    print("All subprocess done.")
#结果
Parent process 7492.
Waiting for all subprocesses done...
Run task 0 (20664)...
Run task 1 (16732)...
Run task 2 (8244)...
Run task 3 (20156)...
Task 0 runs 0.07 seconds.
Run task 4 (20664)...
Task 1 runs 0.12 seconds.
Task 2 runs 2.07 seconds.
Task 3 runs 2.18 seconds.
Task 4 runs 2.82 seconds.
All subprocess done.
#解析:注意到输出结果task0,1,2,3是立刻执行的,而task4要等前面某个task完成后才执行,这是因为pool的默认大小我这儿设置为4,因此,最多同时执行4个进程。
#这是pool有意设计的限制,并不是操作系统的限制。如果改成p = pool(5)就可以同时跑个进程。
#由于pool默认大小是CPU的核数,如果自己电脑拥有8核CPU,那么至少需要提交9个子进程才能看到上面的结果。

       3.子进程

       很多时候,子进程并不是自身,而是一个外部进程。我们创建了子进程后,还需要控制子进程的输入输出。subprocess模块可以让我们非常方便地启动一个子进程,然后控制其输入和输出。下面示例演示如何在Python代码中运行命令nslookup <某域名>,这和命令行直接运行的效果一样:

import subprocess
print("$ nslookup www.python.org")
r = subprocess.call(['nslookup','www.python.org'])
print('Exit code:',r)
#结果
$ nslookup www.python.org
非权威应答:
服务器:  dec3000.xjtu.edu.cn
Address:  202.117.0.20

名称:    dualstack.python.map.fastly.net
Addresses:  2a04:4e42:12::223
	  151.101.76.223
Aliases:  www.python.org

Exit code: 0

       如果子进程还需要输入,则可以通过communicate()方法输入:

import subprocess
print('$ nslookup')
p = subprocess.Popen(['nslookup'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, err = p.communicate(b'set q=mx\npython.org\nexit\n')
#print(output.decode('utf-8'))#UTF-8编码会报错
print(output.decode('gbk'))
print('Exit code:', p.returncode)

#上述代码相当于在命令行执行命令nslookup,然后手动输入:
#set q=mx 
#python.org
#exit

#结果:
$ nslookup
默认服务器:  dec3000.xjtu.edu.cn
Address:  202.117.0.20

> > 服务器:  dec3000.xjtu.edu.cn
Address:  202.117.0.20

python.org	MX preference = 50, mail exchanger = mail.python.org

python.org	nameserver = ns4.p11.dynect.net
python.org	nameserver = ns3.p11.dynect.net
python.org	nameserver = ns2.p11.dynect.net
python.org	nameserver = ns1.p11.dynect.net
mail.python.org	internet address = 188.166.95.178
ns1.p11.dynect.net	internet address = 208.78.70.11
ns2.p11.dynect.net	internet address = 204.13.250.11
ns3.p11.dynect.net	internet address = 208.78.71.11
ns4.p11.dynect.net	internet address = 204.13.251.11
mail.python.org	AAAA IPv6 address = 2a03:b0c0:2:d0::71:1
> 
Exit code: 0

        4.进程间通信

       进程之间肯定是需要通信的,操作系统提供了很多机制来实现进程间的通信。Python的multiprocessing模块包装了底层机制,提供了Queue、Pipes等多种方式来交换数据。接下来示例以Queue为例,在父进程中创建两个子进程,一个往Queue里写数据,一个从Queue里读数据:

from multiprocessing import Process,Queue
import os,time,random

#写数据进程执行的代码
def write(q):
    print('Process to write: %s' % os.getpid())
    for value in ['A', 'B', 'C']:
        print('Put %s to queue...' % value)
        q.put(value)
        time.sleep(random.random())

#读数据进程执行的代码
def read(q):
    print('Process to read: %s' % os.getpid())
    while True:
        value = q.get(True)
        print('Get %s from queue.' % value)

if __name__=='__main__':
    #父进程创建Queue,并传给各个子进程
    q = Queue()
    pw = Process(target=write, args=(q,))
    pr = Process(target=read, args=(q,))
    #启动子进程pw,写入:
    pw.start()
    #启动子进程pr,读取:
    pr.start()
    #等待pw结束
    pw.join()
    #pr进程是死进程,无法等待其结束,只能强行终止
    pr.terminate()
#结果
Process to write: 17308
Put A to queue...
Process to read: 3448
Get A from queue.
Put B to queue...
Get B from queue.
Put C to queue...
Get C from queue.

       总结:

       a、在Unix/Linux下,可以使用fork()调用实现多进程。要实现跨平台的多进程,可以使用multiprocessing模块

       b、在Unix/Linux下,multiprocessing模块封装了fork()调用,使我们不需要关注fork()细节。由于Windows没有fork调用,因此,multiprocessing需要“模拟出”fork的效果,父进程所有python对象都必须通过pickle序列化再传到子进程去。所以,如果multiprocessing在Windows下调用失败了,要先考虑是不是pickle失败了

       c、进程间通信是通过QueuePipes等实现的。

三、多线程

       前面第一部分“进程和线程”中我们已经提到,多任务可以由多进程完成(多进程模式),也可以由一个进程内的多线程完成(多线程模式)。由于线程是操作系统直接支持的执行单元,因此高级语言通常都内置多线程支持。Python中的线程是真正Posix Thread。

       1.threading

       Python的标准库提供了两个模块:_threadthreading,_thread是低级模块,threading是高级模块,对_thread进行了封装。绝大多数我们只需要使用threading这个高级模块。

       启动一个线程就是把一个函数传入并创建Thread实例,然后调用start()开始执行:

import time,threading
#新线程执行的代码
def loop():
    print("thread %s is running..." %threading.current_thread().name)
    n = 0
    while n < 5:
        n += 1
        print("thread %s >>> %s" %(threading.current_thread().name,n))
        time.sleep(1)
    print("thread %s ended." %threading.current_thread().name)

print("thread %s is running..." %threading.current_thread().name)
t = threading.Thread(target=loop,name='LoopThread') #创建线程实例
t.start() #开始执行线程
t.join()
print("thread %s ended." %threading.current_thread().name)

#结果
hread MainThread is running...
thread LoopThread is running...
thread LoopThread >>> 1
thread LoopThread >>> 2
thread LoopThread >>> 3
thread LoopThread >>> 4
thread LoopThread >>> 5
thread LoopThread ended.
thread MainThread ended.
#解析:由于任何进程默认就会启动一个线程,我们把该线程称为主线程,主线程就可以启动新的线程。Python的threading模块有个current_thread()函数,它永远返回当前线程的实例。
#主线程实例名字叫MainThread,子线程的名字在创建时指定,这里我们用LoopThread命名子线程。名字仅仅在打印时用来显示,完全没有其他意义,如果不起名字Python就自动给线程命名为Thread-1,Thread-2...

        2.Lock

       多进程和多线程最大的不同在于:多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响;而在于多线程中,所有变量都由所有线程共享。所以,任何一个变量都可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。

#简单模拟银行存取款
import time,threading
sum = 0#假定这是银行存款

def change_it(n):
    #先存后取,最终结果应该为0
    global sum
    sum = sum + n
    sum = sum - n

def run_thread(n):
    for i in range(100000):
        change_it(n)

t1 = threading.Thread(target=run_thread,args=(5,))
t2 = threading.Thread(target=run_thread,args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(sum)

       我们定义了一个共享变量sum,初始值为0,并且启动两个线程,先存后取,理论上结果应该为0,但是,由于线程的调度是由操作系统决定的,当t1、t2交替执行时,只要循环次数足够多,sum的结果就不一定是0了。

#第一种情况:代码正常执行
初始值 sum = 0
t1: x1 = sum + 5 # x1 = 0 + 5 = 5
t1: sum = x1     # sum = 5
t1: x1 = sum - 5 # x1 = 5 - 5 = 0
t1: sum = x1     # sum = 0

t2: x2 = sum + 8 # x2 = 0 + 8 = 8
t2: sum = x2     # sum = 8
t2: x2 = sum - 8 # x2 = 8 - 8 = 0
t2: sum = x2     # sum = 0    
结果 sum = 0

#第二种情况:t1和t2是交替运行
初始值 sum = 0
t1: x1 = sum + 5  # x1 = 0 + 5 = 5
t2: x2 = sum + 8  # x2 = 0 + 8 = 8
t2: sum = x2      # sum = 8
t1: sum = x1      # sum = 5

t1: x1 = sum - 5  # x1 = 5 - 5 = 0
t1: sum = x1      # sum = 0
t2: x2 = sum - 8  # x2 = 0 - 8 = -8
t2: sum = x2      # sum = -8
结果 sum = -8

       可能出现上述两种不同结果究其原因,是因为修改sum需要多条语句,而执行这几条语句时,线程可能中断,从而导致多个线程把同一个对象内容改乱了。如果我们要确保sum计算正确,就要给change_it()上一把锁,当某个线程开始执行change_it()时,我们说该线程因为获得了锁,因此其他线程不能同时执行change_it(),只能等待,直到锁被释放后,获得该锁以后才能改。由于锁只有一个,无论多少线程,同一时刻最多只有一个线程持有该锁,所以,不会造成修改的冲突。创建一个锁通过threading.Lock()来实现:

#模拟银行存款对存取款步骤加锁
import time,threading
sum = 0#假定这是银行存款
lock = threading.Lock()
def change_it(n):
    #先存后取,最终结果应该为0
    global sum
    sum = sum + n
    sum = sum - n

def run_thread(n):
    for i in range(100000):
        #先要获取锁
        lock.acquire()
        try:
            #修改账户余额
            change_it(n)
        finally:
            #修改完释放锁
            lock.release()

t1 = threading.Thread(target=run_thread,args=(5,))
t2 = threading.Thread(target=run_thread,args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(sum)

#结果
0

       注意:①当多个线程同时执行lock.acquire()时,只有一个线程可以成功地获取锁,然后继续执行代码,其他线程继续等待直到获得锁为止。②获得锁的线程用完以后一定要释放锁,否则那些苦苦等待锁的线程将永远等待下去,成为死线程。所以我们用try...finally来确保锁一定会被释放。③锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处当然也很多,首先就是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率大大地下降了。其次,由于可以存在多个锁,不同线程持有不同的锁,当试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强行终止。

       3.多核CPU

       用C、C++或Java来写死循环,直接可以把全部CPU核心跑满,4核可以跑到400%,8核可以跑到800%,但是Python却做不到。这是因为Python的线程虽然是真正的线程,但是解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。

       GIL是Python解释器设计的历史遗留问题,通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。所以,在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。

       总结:

       a、多线程编程,模型复杂,容易发生冲突,必须用锁加以隔离,同时,又要小心死锁发生。

       b、Python解释器由于设计时有GIL全局锁,导致多线程无法利用多核。多线程并发在Python中只是一个梦!

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值