Java Python 多线程区别 GIL 以及线程安全 内核级线程

threading包比thread提供的功能更全面,所以这里使用threading为例

不过本文不想过多讨论基础操作, 我比较好奇的是Python的GIL和线程安全问题(Java写多了)

import threading

def say(name):
    for i in range(5):
        print("from thread "+str(name));

t1 = threading.Thread(target=say,args=("1"));
t2 = threading.Thread(target=say,args=("2"));

t1.start();
t2.start();

上面这个例子会交替打印 message1 和message2 

我们稍微做一下改变,让这两个线程一直循环下去好观察cpu的占用率

import threading

def say(name):
    while(True):
        print("from thread "+str(name));

t1 = threading.Thread(target=say,args=("1"));
t2 = threading.Thread(target=say,args=("2"));

t1.start();
t2.start();

因为window7本身是内核级进程(也是大多数Linux现在的默认设置),所以可以很清楚的观察到两个线程被平均分配到了4个内核中(我的环境是i7 4内核)

程序启动以后,4个核心的占用率直接飙升

即使使用一个线程进行while(true)循环你也会看到4个内核占用率同时提高,因为操作系统使用的线程实际上是一种轻量级进程

我们来测试一下使用python多线程执行密集型运算

import threading;
import sys;
import math;
import time;
from decimal import *;

def bellard(n):
   pi=Decimal(0)
   k=0
   while k < n:
       pi+=(Decimal(-1)**k/(1024**k))*( Decimal(256)/(10*k+1)+Decimal(1)/(10*k+9)-Decimal(64)/(10*k+3)-Decimal(32)/(4*k+1)-Decimal(4)/(10*k+5)-Decimal(4)/(10*k+7)-Decimal(1)/(4*k+3))
       k+=1
   pi=pi*1/(2**6)
   return pi

def say(name):
    start = time.clock();
    for i in range(100):
        p = bellard(1000);
#        print("running..."+str(p));
    end = time.clock();
    print ("read: %f s" % (end - start));

t1 = threading.Thread(target=say,args=("1"));
t2 = threading.Thread(target=say,args=("2"));

t1.start();
t2.start();

双线程运行pi计算上面的代码会给出

read: 51.493309 s
read: 51.516034 s

改成单线程之后会得到如下结果

read: 24.826255 s

你会惊喜的发现时间缩短一倍, 那我们猜测如果3个线程的话会得到3倍左右的运算时间

read: 75.466136 s
read: 75.925743 s
read: 76.911878 s

Python使用一种叫做GIL ( 全局解释器锁 ) 的机制来调度线程

在主轮询中(这里鄙视一下某些书的翻译,把轮询翻译成循环) 同时只能有一个线程执行

有点像单核cpu执行多线程的模式,然后而又有点区别, java在单核cpu多线程时也需要考虑线程安全,因为有些变量是多线程之间共享的

Python这种一刀切的方式,干脆相当于在所有方法前都加上了 synchronized 同步

这里还有一个问题要强调一下, java 中 volatile 并不能保证变量是线程安全的

volatile 只是告诉虚拟机在每次使用这个变量时,去堆内存中重新读一下, 如果主内存变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化

这里我们用java的多线程模型做对比

java没有GIL, 是真实的多线程,也就是说一个线程在4内核上需要10秒钟的运算,两个线程在4内核上也需要10秒,因为java可以真正同时使用两个内核进行运算(两个内核各运行10秒)

这里需要注意以下几点

1. 不要在循环中使用System.out.print输出任何字符,因为会造成IO消耗,等待时间大部分都是IO造成的,测试不准确

2. 不要使用惯用的sleep方法来测试,sleep方法只是计时器,不能造成cpu密集型运算

public class Test {
    static Runnable r = new Runnable(){
        @Override
        public void run() {
            long startTime=System.currentTimeMillis(); 
            for(int i=0;i<10000000;i++){
                cut(20L);
            }
            System.out.println("running...");
            long endTime=System.currentTimeMillis(); 
            System.out.println("程序运行时间: "+(endTime-startTime)+"ms");
        }
        
    };
    
    public static void main(String args[]) throws InterruptedException{
        
        Thread t1 = new Thread(r);
        Thread t2 = new Thread(r);
        t1.start();
        t2.start();
        
    }
    
    static void cut(Long n){  
        double y=1.0;  
        for(Long i=0L;i<=n;i++){  
            double π=3*Math.pow(2, i)*y;  
//            System.out.println("第"+i+"次切割,为正"+(6+6*i)+"边形,圆周率π≈"+π);  
            y=Math.sqrt(2-Math.sqrt(4-y*y));  
        }  
    }
}
 

上面的程序在调用两个线程时会给出下面的结果

running…
程序运行时间: 14933ms
running…
程序运行时间: 14937ms

 

在调用三个线程时会给出

running…
程序运行时间: 15639ms
running…
程序运行时间: 15654ms
running…
程序运行时间: 15689ms
 

调用四个线程

running…
程序运行时间: 16069ms
running…
程序运行时间: 16099ms
running…
程序运行时间: 16154ms
running…
程序运行时间: 16197ms

这个时候我的4内核cpu应该已经处于饱和状态了,最后我们尝试调用8个线程

running…
程序运行时间: 31569ms
running…
程序运行时间: 32132ms
running…
程序运行时间: 32156ms
running…
程序运行时间: 32166ms
running…
程序运行时间: 32132ms
running…
程序运行时间: 32208ms
running…
程序运行时间: 32370ms
running…
程序运行时间: 32321ms
 

正如所料,消耗了差不多4线程的两倍时间,因为我的cpu已经饱和了

java的jvm实际上只有一个进程,但是由于使用了内核级线程(轻量级进程),操作系统会把运算工作分发给4个内核同时运行,是真正的多线程