背景知识

  • 软件是如何驱动硬件的?
    硬件是需要相关的驱动程序才能执行,而驱动程序是安装在操作系统内核中。如果写了一个程序 A,A 程序想操作硬件工作,首先需要进行系统调用,由内核去找对应的驱动程序驱使硬件工作。而驱动程序怎么让硬件工作的呢?驱动程序作为硬件和操作系统之间的媒介,可以把操作系统中相关的指令翻译成硬件能够识别的电信号,同时,驱动程序也可以将硬件的电信号转为操作系统能够识别的指令。
  • 进程、轻量级进程、线程关系
    一个进程由于所运行的空间不同,被分为内核线程和用户进程,之所有称之为内核线程,是因为其不拥有虚拟地址空间。如果创建一个新的用户进程,会分配一个新的虚拟地址空间,不同用户进程之间资源是隔离的。由于创建一个新的进程需要消耗很多的资源,并且在进程之间切换的代价也很昂贵,因此引入了轻量级进程。轻量级进行本质上也是对内核线程的高层抽象,虽然不同的轻量级进程之间可以共享某些资源,但由于轻量级进程本质上还是内核线程,如果进行轻量级线程之间的切换,需要进行系统调用,代价也是比较昂贵的。内核本质上只能感知到进程的存在,像不同语言的多线程技术,是在用户进程的基础上创建的线程库,线程本身不参与处理器竞争,而是由其所属的用户进程参与处理器的竞争。
  • 如何理解用户态和内核态
    首先我们需要理解到计算机资源是有限的,不管是 CPU 资源、内存资源、IO 资源、网络资源,为了保证这些资源的合理利用,需要有一个管控机制,而这个管控机制都是交于操作系统来处理的。用户态和内核态是操作系统的一种逻辑划分,本质上是进行权限控制,处于用户态的进程可以直接使用分配给其的内存空间,但如果想使用 CPU 等稀缺资源,处于用户态的进程就没有这个权限了,必须通过系统调用,让当前进程进入内核态,这样可以有更大的权限去申请 CPU 资源、内存资源、IO 资源等;
  
操作系统线程模型
图片.png

  java 语言
  线程模型 在 Java 诞生之初,在 Java 中就引入了线程,最初称之为 “绿色线程”,完全由 JVM 进行管理,这和操作系统用户线程是多对一的实现,但随着操作系统对线程支持越来越强大,java 中的线程实现采用了一对一的实现,即一个 java 线程对应于一个操作系统用户线程,但是这个线程的堆栈大小是固定的,随着线程数量创建过多,可能导致内存溢出。在 java19 版本中引入了虚拟线程的概念,虚拟线程有一个动态的堆栈,可以增大和缩小,这和操作系统用户线程之间是一个多对多的关系,随着后面的发展,java 中的线程模型会变得越来越强大。
图片.png
  优缺点 作为一对一的线程模型维护起来比较简单,但是由于每一个线程栈信息是固定的,不利于创建大量的线程,并且多线程操作时可能涉及频繁的系统调用,上下文切换代价高。
  使用方式 (以生产者消费者模型来说明)
  1. public class ThreadTest {

  2.     public static final Object P = new Object();

  3.     static List<Integer> list = new ArrayList<>();

  4.     @Test
  5.     public void test() throws Exception {

  6.         Thread thread1 = new Thread(()-> {
  7.             while(true) {
  8.                 try {
  9.                     product();
  10.                 }catch (Exception e) {
  11.                     e.printStackTrace();
  12.                 }
  13.             }
  14.         });
  15.         Thread thread2 = new Thread(() -> {
  16.             while(true) {
  17.                 try {
  18.                     consume();
  19.                 }catch (Exception e) {
  20.                     e.printStackTrace();
  21.                 }
  22.             }
  23.         });
  24.         thread1.start();
  25.         thread2.start();

  26.         thread1.join();
  27.         thread2.join();
  28.     }

  29.     private static void product() throws Exception {
  30.         synchronized (P) {
  31.             if(list.size() == 1) {
  32.                 // 让出锁
  33.                 P.wait();
  34.             }
  35.             list.add(1);
  36.             System.out.println("produce");
  37.             P.notify();
  38.         }
  39.     }

  40.     private static void consume() throws Exception {
  41.         synchronized (P) {
  42.             if(list.size() == 0) {
  43.                 P.wait();
  44.             }
  45.             list.remove(list.size() - 1);
  46.             System.out.println("consume");
  47.             P.notify();
  48.         }
  49.     }
  50. }

  go 语言
  go 语言线程模型 在 go 语言中,线程模型就是比较强大了,包含了三个概念:内核线程(M)、goroutine (G)、G 的上下文环境(P)。其中 G 表示基于协程创建的用户线程,M 直接关联一个内核线程,P 里面一般存放正在运行的 goroutine 的上下文环境(函数指针、堆栈地址和地址边界等)。
图片.png

  优缺点 go 语言中的线程模型算是很强大了,引用了协程,线程栈大小可以动态调整,很好地避免了 java 中目前的线程模型缺点。

  使用方式 (以生产者消费者模型来说明)
  1. package main

  2. import (
  3.         "fmt"
  4. )

  5. type ThreadTest struct {
  6.         lock chan int
  7. }

  8. func (t *ThreadTest) produce() {
  9.         for {
  10.                 t.lock <- 10
  11.                 fmt.Println("produce:", 10)
  12.         }
  13. }

  14. func (t *ThreadTest) consume() {
  15.         for {
  16.                 v := <-t.lock
  17.                 fmt.Println("consume:", v)
  18.         }
  19. }

  20. func main() {
  21.         maxLen := 10
  22.         t := &ThreadTest{
  23.                 make(chan int, maxLen),
  24.         }
  25.         // 重点在这里,开启新的协程,配合通道,让go的多线程变成非常优雅
  26.         go t.consume()
  27.         go t.produce()
  28.         select {}

  29. }

  c++ 语言
  c++ 语言线程模型 在 c++11 中增加了操作 thread 库,提供对线程操作的进一步封装,而这个库底层是使用了 pthread 库,这个库底层采用了 1:1 线程模型,跟 java 中的线程模型类似。
  优缺点 作为一对一的线程模型维护起来比较简单,但是由于每一个线程栈信息是固定的,不利于创建大量的线程,并且多线程操作时可能涉及频繁的系统调用,上下文切换代价高。
  使用方式 (以生产者消费者模型来说明)
  1. #include
  2. #include
  3. #include
  4. #include  

  5. static const int SIZE = 10;
  6. static const int ITEM_SIZE = 30;

  7. std::mutex mtx;

  8. std::condition_variable not_full;
  9. std::condition_variable not_empty;

  10. int items[SIZE];

  11. static std::size_t r_idx = 0;
  12. static std::size_t w_idx = 0;

  13. void produce(int i) {
  14.     std::unique_lock lck(mtx);
  15.     while((w_idx+ 1) % SIZE == r_idx) {
  16.         std::cout << "队列满了" << std::endl;
  17.         not_full.wait(lck);
  18.     }
  19.     items[w_idx] = i;
  20.     w_idx = (w_idx+ 1) % SIZE;
  21.     not_empty.notify_all();
  22.     lck.unlock();
  23. }

  24. int consume() {
  25.     int data;
  26.     std::unique_lock lck(mtx);
  27.     while(w_idx == r_idx) {
  28.         std::cout << "队列为空" << std::endl;
  29.         not_empty.wait(lck);
  30.     }
  31.     data = items[r_idx];
  32.     r_idx = (r_idx + 1) % SIZE;
  33.     not_full.notify_all();
  34.     lck.unlock();
  35.     return data;
  36. }

  37. void p_t() {
  38.     for(int i = 0; i < ITEM_SIZE; i++) {
  39.         produce(i);
  40.     }
  41. }

  42. void c_t() {
  43.     static int cnt = 0;
  44.     while(1) {
  45.         int item = consume();
  46.         std::cout << "消费第" << item << "个商品" << std::endl;
  47.         if(++cnt == ITEM_SIZE) {
  48.             break;
  49.         }
  50.     }
  51. }

  52. int main() {
  53.     std::thread producer(p_t);
  54.     std::thread consumer(c_t);
  55.     producer.join();
  56.     consumer.join();
  57. }


  python 语言
  python 线程模型 python 中的线程使用了操作系统的原生线程,python 虚拟机使用了一个全局互斥锁(GIL)来互斥线程对 Python 虚拟机的使用,当一个线程获取 GIL 的权限之后,其他的线程必须等待这个线程释放 GIL 锁,索引再多核 CPU 上,python 多线程也会退化为单线程,无法利用多核的优势。
图片.png

  优缺点 python 语言多线程由于 GIL 的存在,在计算密集型场景上,很难体现到优势,并且由于涉及线程切换的代码,反而可能性能还不如单线程好。
  使用方式 (以生产者消费者模型来说明)
  1. #! /usr/bin/python3

  2. import threading
  3. import random
  4. import time

  5. total = 100
  6. lock = threading.Lock()
  7. totalTime = 10
  8. gTime = 0

  9. class Consumer(threading.Thread):
  10.         def run(self):
  11.                 global total
  12.                 global gTime
  13.                 while True:
  14.                         cur = random.randint(10, 100)
  15.                         lock.acquire()
  16.                         if total >= cur:
  17.                                 total -= cur
  18.                                 print("{}使用了{}, 当前剩余{}".format(threading.current_thread(), cur, total))
  19.                         else:
  20.                             print("{}准备使用{},当前剩余{},不足,不能消费".format(threading.current_thread(), cur, total))
  21.                         if gTime == totalTime:
  22.                                lock.release()
  23.                                break
  24.                         lock.release()
  25.                         time.sleep(0.7)

  26. class Producer(threading.Thread):
  27.     def run(self):
  28.            global total
  29.            global gTime
  30.            while True:
  31.                   cur = random.randint(10, 100)
  32.                   lock.acquire()
  33.                   if gTime == totalTime:
  34.                          lock.release()
  35.                          break
  36.                   total += cur
  37.                   print("{}生产了{}, 剩余{}".format(threading.current_thread(), cur, total))
  38.                   gTime+= 1
  39.                   lock.release()
  40.                   time.sleep(0.5)
  41. if __name__ == '__main__':
  42.        t1 = Producer(name="生产者")
  43.        t1.start()
  44.        t2 = Consumer(name="消费者")
  45.        t2.start()

总结
在目前的线程模型中,有 1:1、M:1、M:N 多种线程模型,具体采用哪种线程模型也和硬件和操作系统的支持程度有关,像诞生比较早的语言,普通采用 M:1、1:1 线程模型,像 c++、java。而新诞生不久的 go 语言,采用的是 M:N 线程模型,在多线程的支持上更加强大。
感觉了解一下线程模型还是很有必要的,如果不清楚语言层面上的线程在操作系统层面怎么映射使用,在使用过程中就会不清不楚,可能会踩一些坑,我们都知道在 java 中不同无限的创建线程,这会导致内存溢出,go 语言中对多线程支持更加强大,很多事情不需要我们再去关注了,在语言底层已经帮助我们做了。
每种语言的底层细节太多了,如果想深入研究某一个技术,还是得花精力去研究。

  作者:京东零售 姜昌伟
  来源:京东云开发者社区