分类 其它 下的文章

1、概述

1.1 什么是Subject Alternative Name(证书主体别名)

SAN(Subject Alternative Name) 是 SSL 标准 x509 中定义的一个扩展。
它允许一个证书支持多个不同的域名。通过使用SAN字段,可以在一个证书中指定多个DNS名称(域名)、IP地址或其他类型的标识符,这样证书就可以同时用于多个不同的服务或主机上。
这种灵活性意味着企业不需要为每个域名单独购买和安装证书,从而降低了成本和复杂性。

先来看一看 Google 是怎样使用 SAN 证书的,下面是 Youtube 网站的证书信息:

这里可以看到这张证书的 Common Name 字段是 *.google.com,那么为什么这张证书却能够被 www.youtube.com 这个域名所使用呢。原因就是这是一张带有 SAN 扩展的证书,下面是这张证书的 SAN 扩展信息:


这里可以看到,这张证书的 Subject Alternative Name 段中列了一大串的域名,因此这张证书能够被多个域名所使用。对于 Google 这种域名数量较多的公司来说,使用这种类型的证书能够极大的简化网站证书的管理。

1.2 SAN的由来

在早期的互联网中,每个SSL/
TLS证书通常只包含一个CN字段,用于标识单一的域名或IP地址。随着虚拟主机技术的发展和企业对于简化管理的需求增加,需要一种机制能够允许单个证书有效地代表多个域名或服务。例如,一个企业可能拥有多个子域名,希望用单一的证书来保护它们。

为了解决这个问题,SAN扩展被引入到X.509证书标准中。最初在1999年的RFC 2459中提出,SAN提供了一种方法来指定额外的主题名称,从而使得一个证书能有效地代表多个实体。

1.3 SAN的作用和重要性

  1. 多域名保护:SAN使得一个证书可以保护多个域名和子域名,减少了管理的复杂性和成本。
  2. 灵活性增强:企业和组织可以更灵活地管理和部署证书,根据需要快速调整和扩展保护范围。
  3. 兼容性:随着技术的发展,现代浏览器和客户端软件都已经支持SAN。它们会优先检查SAN字段,如果找到匹配项,通常不会再回退到检查CN。

1.4 如何使用SAN

在申请SSL/TLS证书时,可以指定一个或多个SAN值。这些值通常是你希望证书保护的域名或IP地址。证书颁发机构(CA)在颁发证书时会验证这些信息的正确性,并将它们包含在证书的SAN字段中。

2、如何在OpenSSL证书中添加SAN

在OpenSSL中创建证书时添加SAN,需要在配置文件中添加一个subjectAltName扩展。这通常涉及到以下几个步骤:

  1. 准备配置文件:在配置文件中指定subjectAltName扩展,并列出要包含的域名和IP地址。
  2. 生成密钥:使用OpenSSL生成私钥。
  3. 生成证书签发请求(CSR):使用私钥和配置文件生成CSR,该CSR将包含SAN信息。
  4. 签发证书:使用CA(证书颁发机构)或自签名方式签发证书,证书中将包含SAN信息。

2.1 准备带有SAN扩展的证书请求配置文件

  • 创建带有SAN扩展字段的证书签名请求(CSR)的配置文件, alt_names 的配置段为SAN扩展字段配置。确保在将其保存至文件(如csr.conf)。

[ req ]  
default_bits = 2048  
prompt = no  
default_md = sha256  
req_extensions = v3_ext  
distinguished_name = dn  
  
[ dn ]  
C = CN  
ST = ShangDong
L = SZ  
O = Wise2c  
OU = Wise2c  
CN = zmc  
  
[ req_ext ]  
subjectAltName = @alt_names  
  
[ alt_names ]  
DNS.1 = *.zmcheng.com  
DNS.2 = *.zmcheng.net  
DNS.3 = *.zmc.com  
DNS.4 = *.zmc.net  
  
[ v3_ext ]  
basicConstraints=CA:FALSE  
keyUsage=keyEncipherment,dataEncipherment  
extendedKeyUsage=serverAuth,clientAuth  
subjectAltName=@alt_names

注意 1:SAN扩展字段不仅可以配置域名,还可以配置邮箱、IP地址、URI。

// Subject Alternate Name values. (Note that these values may not be valid
// if invalid values were contained within a parsed certificate. For
// example, an element of DNSNames may not be a valid DNS domain name.)
DNSNames       []string
EmailAddresses []string
IPAddresses    []net.IP // Go 1.1
URIs           []*url.URL // Go 1.10

2.2 生成密钥

  • 生成密钥位数为 2048 的 ca.key

openssl genrsa -out ca.key 2048
  • 依据 ca.key 生成 ca.crt (使用 -days 参数来设置证书有效时间):

openssl req -x509 -new -nodes -key ca.key -subj "/CN=zmc" -days 10000 -out ca.crt
  • 生成密钥位数为 2048 的 server.key

openssl genrsa -out server.key 2048

2.3 生成证书签发请求

  • 基于配置文件生成证书签名请求

openssl req -new -key server.key -out server.csr -config csr.conf

2.4 签发证书

  • 使用 ca.key、ca.crt 和 server.csr 生成服务器证书

openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \-CAcreateserial -out server.crt -days 10000 \-extensions v3_ext -extfile csr.conf
  • 查看证书,可以看到创建出带有SAN扩展字段证书

openssl x509  -noout -text -in ./server.crt

3、客户端验证服务端证书步骤

客户端(一般是浏览器)在请求HTTPS网站时,验证服务端证书的过程确实是一个复杂且关键的安全步骤。以下是以浏览器为例,说下浏览器验证服务端证书步骤:

  1. 发起HTTPS请求:
    • 用户通过浏览器输入HTTPS网站的URL,并按下回车键或点击链接,浏览器开始发起HTTPS请求。
  2. 服务端响应并发送证书:
    • 服务器在收到HTTPS请求后,会将其SSL/TLS证书发送给浏览器。这个证书包含了服务器的公钥、证书颁发机构(CA)的信息、证书的有效期、以及可能包括的SAN(主题备用名称)和CN(通用名称)等字段。
  3. 浏览器验证证书颁发机构(CA):
    • 浏览器会检查证书是否由受信任的CA签发。浏览器内置了一个受信任的CA列表(也称为根证书列表),它会使用这些CA的公钥来验证证书链中每一级证书签名的有效性,直到找到信任的根证书。
  4. 检查证书有效期:
    • 浏览器会验证证书的有效期,确保当前时间在证书的有效期内。
  5. 验证证书域名

    • 浏览器会检查证书中的SAN字段(如果存在)和CN字段,确保它们与用户正在访问的域名相匹配。如果域名不匹配,浏览器将认为证书无效,并显示警告信息。
  6. 检查证书吊销状态(可选):
    • 浏览器可能会通过OCSP(在线证书状态协议)或CRL(证书吊销列表)来检查证书是否已被吊销。这一步是可选的,但有助于及时发现并阻止使用已泄露私钥的证书。
  7. 生成会话密钥(TLS握手过程的一部分):
    • 如果证书验证通过,浏览器和服务器将进行TLS握手,以协商一个安全的会话密钥。这个密钥将用于后续通信的加密和解密。
  8. 加密通信:
    • 一旦会话密钥协商完成,浏览器和服务器就可以开始加密通信了。浏览器会使用会话密钥加密发送给服务器的数据,而服务器则使用相同的会话密钥解密这些数据。

注意事项

  • 如果浏览器在验证证书的过程中发现任何问题(如证书不受信任、已过期、域名不匹配等),它将向用户显示警告信息,并可能阻止用户继续访问该网站。
  • SAN字段的优先级通常高于CN字段。如果证书中同时包含了SAN和CN字段,并且它们之间的域名不一致,浏览器将优先使用SAN字段中的域名进行验证。
  • 浏览器内置的受信任CA列表可能会随着时间的推移而更新,以反映新的CA或撤销旧的CA。因此,用户应该保持其浏览器和操作系统的更新,以确保其能够识别最新的受信任CA。

以上步骤详细描述了浏览器在请求HTTPS网站时验证服务端证书的过程。这个过程确保了用户与服务器之间的通信是安全、可信的。

4、示例——Kubectl客户端访问KubeApiserver

4.1 环境

KubeApiserver地址:10.20.32.205:6443

KubeApiserver证书信息:

[root@member-cluster2-master1 pki]# pwd
/etc/kubernetes/pki
[root@member-cluster2-master1 pki]# ls
apiserver.crt  apiserver-kubelet-client.crt  ca.crt  front-proxy-ca.crt  front-proxy-client.crt  sa.key
apiserver.key  apiserver-kubelet-client.key  ca.key  front-proxy-ca.key  front-proxy-client.key  sa.pub
[root@member-cluster2-master1 pki]# openssl x509  -noout -text -in ./apiserver.crt 
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 495742113187184862 (0x6e13ad34cb940de)
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN = kubernetes
        Validity
            Not Before: Sep 11 01:13:49 2024 GMT
            Not After : Aug 18 01:16:09 2124 GMT
        Subject: CN = kube-apiserver
       .......

            X509v3 Subject Alternative Name: 
                DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, DNS:lb.zmc.local, DNS:localhost, DNS:member-cluster2-master1, DNS:member-cluster2-master1.cluster.local, IP Address:10.234.0.1, IP Address:10.20.32.205, IP Address:127.0.0.1
    Signature Algorithm: sha256WithRSAEncryption
       .......

4.2 Kubectl正常访问KubeApiserver(证书SAN扩展字段值包含访问地址)

  • 通过KubeApiserver ip地址访问

  • 通过域名主机映射访问,域名在SAN里面

4.3 Kubectl异常访问KubeApiserver(证书SAN扩展字段值不包含访问地址)

  • 通过域名主机映射访问,域名不在SAN里面,客户端验证证书失败。

5、其他——KubeApiserver证书SAN扩展字段是否要把整个集群IP都列上

第一次接触Kubernetes并安装集群时,网上翻了很多博客,在生成KubeApiserver证书环节,都写着要把整个待安装集群的ip地址都写到KubeApiserver证书请求文件的SAN扩展字段里面,也没讲什么原因。

至于在生成KubeApiserver证书环节是否要把整个集群的ip地址都写到KubeApiserver证书请求文件的SAN扩展字段里面,还是要看集群里面的组件如何访问KubeApiserver,如果:

  • 调度器(kube-scheduler)、控制器(kube-controller-manage)都是直接和当前节点的kube-apiserver组件直接交互;
  • kubelet、kubectl、kube-proxy通过VIP代理到kube-apiserver组件。

对于以上这种情况,SAN扩展字段可以只写VIP(负责均衡器IP等)、MasterIPs即可,WorkerIP没必要写到SAN扩展字段里面。

6、结论

Subject Alternative Name(主体别名)是一个证书扩展字段,用于指定证书所适用的主机名列表。当客户端连接到服务器时,会检查服务器返回的证书中的主体别名是否包含与请求的主机名匹配的条目。

SAN的引入极大地增强了数字证书的功能和应用范围,使得管理和保护多域名环境变得更加高效和简便。作为现代网络安全的一个关键组成部分,理解并正确使用SAN对于任何需要部署SSL/TLS保护的个人或组织都至关重要。随着网络环境的不断演变和新需求的不断出现,SAN将继续发挥其在保护网络通信安全中的重要作用。无论是IT专业人员还是普通用户,都应该了解SAN的基本概念和实践,以确保在数字世界中安全地通信和交互。

队列
是咱们开发中经常使用到的一种数据结构,它与

的结构类似。然而栈是后进先出,而队列是先进先出,说的专业一点就是
FIFO
。在生活中到处都可以找到队列的,最常见的就是排队,吃饭排队,上地铁排队,其他就不过多举例了。

队列的模型

在数据结构中,和排队这种场景最像的就是
数组
了,所以我们的队列就用数组去实现。在排队的过程中,有两个基本动作就是
入队

出队
,入队就是从队尾插入一个元素,而出队就是从队头移除一个元素。基本的模型我们可以画一个简图:

看了上面的模型,我们很容易想到使用数组去实现队列,

  1. 先定义一个数组,并确定数组的长度,我们暂定数组长度是5,而上面图中的长度是一样的;
  2. 再定义两个数组下标,
    front

    tail
    ,front是队头的下标,每一次出队的操作,我们直接取front下标的元素就可以了。tail是队尾的下标,每一次入队的操作,我们直接给tail下标的位置插入元素就可以了。

我们看一下具体的过程,初始状态是一个空的队列,

队头下标和队尾下标都是指向数组中的第0个元素,现在我们插入第一个元素“a”,如图:

数组的第0个元素赋值“a”,tail的下标+1,由指向第0个元素变为指向第1个元素。这些变化我们都要记住啊,后续在编程实现的过程中,每一个细节都不能忽略。然后我们再做一次出队操作:

第0个元素“a”在数组中移除了,并且front下标+1,指向第1个元素。

这些看起来不难实现啊,不就是给数组元素赋值,然后下标+1吗?但是我们想一想极端的情况, 我们给数组的最后一个元素赋值后,数组的下标怎么办?

tail如果再+1,就超越了数组的长度了呀,这是明显的
越界
了。同样front如果取了数组中的最后一个元素,再+1,也会越界。这怎么办呢?

循环数组

我们最开始想到的方法,就是当tail下标到达数组的最后一个元素的时候,对数组进行扩容,数组的长度又5变为10。这种方法可行吗?如果一直做入队操作,那么数组会无限的扩容下去,占满磁盘空间,这是我们不想看到的。

另外一个方法,当front或tail指向数组最后一个元素时,再进行+1操作,我们将下标指向队列的开头,也就是第0个元素,形成一个循环,这就叫做
循环数组
。那么这里又引申出一个问题,我们的下标怎么计算呢?

  1. 数组的长度是5;
  2. tail当前的下标是4,也就是数组的最后一个元素;
  3. 我们给最后一个元素赋值后,tail怎么由数组的最后一个下标4,变为数组的第一个下标0?

这里我们可以使用
取模
来解决:
tail = (tail + 1) % mod
,模(mod)就是我们的数组长度5,我们可以试一下,tail当前值是4,套入公式计算得到0,符合我们的需求。我们再看看其他的情况符不符合,假设tail当前值是1,套入公式计算得出2,也相当于是+1操作,没有问题的。只有当tail+1=5时,才会变为0,这是符合我们的条件的。那么我们实现队列的方法就选用循环数组,而且数组下标的计算方法也解决了。

队列的空与满

队列的空与满对入队和出队的操作是有影响的,当队列是满的状态时,我们不能进行入队操作,要等到队列中有空余位置才可以入队。同样当队列时空状态时,我们不能进行出队操作,因为此时队列中没有元素,要等到队列中有元素时,才能进行出队操作。那么我们怎么判断队列的空与满呢?

我们先看看队列空与满时的状态:

空时的状态就是队列的初始状态,front和tail的值是相等的。

满时的状态也是front == tail,我们得到的结论是,front == tail时,队列不是空就是满,那么到底是空还是满呢?这里我们要看看是什么操作导致的front == tail,如果是入队导致的front == tail,那么就是满;如果是出队导致的front == tail,那就是空。

手撸代码

好了,队列的模型以及基本的问题都解决了,我们就可以手撸代码了,我先把代码贴出来,然后再给大家讲解。

public class MyQueue<T> {

    //循环数组
    private T[] data;
    //数组长度
    private int size;
    //出队下标
    private int front =0;
    //入队下标
    private int tail = 0;
    //导致front==tail的原因,0:出队;1:入队
    private int flag = 0;

    //构造方法,定义队列的长度
    public MyQueue(int size) {
        this.size = size;
        data = (T[])new Object[size];
    }
    
    /**
     * 判断对队列是否满
     * @return
     */
    public boolean isFull() {
        return front == tail && flag == 1;
    }

    /**
     * 判断队列是否空
     * @return
     */
    public boolean isEmpty() {
        return front == tail && flag == 0;
    }

    /**
     * 入队操作
     * @param e
     * @return
     */
    public boolean add(T e) {
        if (isFull()) {
            throw new RuntimeException("队列已经满了");
        }
        data[tail] = e;
        tail = (tail + 1) % size;
        if (tail == front) {
            flag = 1;
        }

        return true;
    }

    /**
     * 出队操作
     * @return
     */
    public T poll() {
        if (isEmpty()) {
            throw new RuntimeException("队列中没有元素");
        }
        T rtnData = data[front];
        front = (front + 1) % size;
        if (front == tail) {
            flag = 0;
        }
        return rtnData;
    }
}

在类的开始,我们分别定义了,循环数组,数组的长度,入队下标,出队下标,还有一个非常重要的变量
flag
,它表示导致front == tail的原因,0代表出队,1代表入队。这里我们初始化为0,因为队列初始化的时候是空的,而且front == tail,这样我们判断
isEmpty()
的时候也是正确的。

接下来是构造方法,在构造方法中,我们定义了入参
size
,也就是队列的长度,其实就是我们循环数组的长度,并且对循环数组进行了初始化。

再下面就是判断队列空和满的方法,实现也非常的简单,就是依照
上一小节
的原理。

然后就是入队操作,入队操作要先判断队列是不是已经满了,如果满了,我们进行报错,不进行入队的操作。有的同学可能会说,这里应该等待,等待队列有空位了再去执行。这种说法是非常正确的,我们先把最基础的队列写完,后面还会再完善,大家不要着急。下面就是对循环数组的tail元素进行赋值,赋值后,使用我们的公式移动tail下标,tail到达最后一个元素时,通过公式计算,可以回到第0个元素。最后再判断一下,这个入队操作是不是导致了front==tail,如果导致了,就将flag置为1。

出队操作和入队操作类似,只不过是取值的步骤,这里不给大家详细解释了。

我们做个简单的测试吧,

 public static void main(String[] args) {
        MyQueue<String> myQueue = new MyQueue<>(5);
        System.out.println("isFull:"+myQueue.isFull()+" isEmpty:"+myQueue.isEmpty());
        myQueue.add("a");
        System.out.println("isFull:"+myQueue.isFull()+" isEmpty:"+myQueue.isEmpty());
        myQueue.add("b");
        myQueue.add("c");
        myQueue.add("d");
        myQueue.add("e");
        System.out.println("isFull:"+myQueue.isFull()+" isEmpty:"+myQueue.isEmpty());
        myQueue.add("f");
    }

我们定义长度是5的队列,分别加入
a b c d e f
6个元素,并且看一下空和满的状态。

打印日志如下:

isFull:false isEmpty:true
isFull:false isEmpty:false
isFull:true isEmpty:false
Exception in thread "main" java.lang.RuntimeException: 队列已经满了
	at org.example.queue.MyQueue.add(MyQueue.java:29)
	at org.example.queue.MyQueue.main(MyQueue.java:82)

空和满的状态都是对的,而且再插入f元素的时候,报错了”队列已经满了“,是没有问题的。出队的测试这里就不做了,留个小伙伴们去做吧。

并发与等待

队列的基础代码已经实现了,我们再看看有没有其他的问题。对了,第一个问题就是
并发
,我们多个线程同时入队或者出队时,就会引发问题,那么怎么办呢?其实也很简单,加上
synchronized
关键字就可以了,如下:

/**
 * 入队操作
 * @param e
 * @return
 */
public synchronized boolean add(T e) {
    if (isFull()) {
        throw new RuntimeException("队列已经满了");
    }
    data[tail] = e;
    tail = (tail + 1) % size;
    if (tail == front) {
        flag = 1;
    }

    return true;
}

/**
 * 出队操作
 * @return
 */
public synchronized T poll() {
    if (isEmpty()) {
        throw new RuntimeException("队列中没有元素");
    }
    T rtnData = data[front];
    front = (front + 1) % size;
    if (front == tail) {
        flag = 0;
    }
    return rtnData;
}

这样入队出队操作就不会有并发的问题了。下面我们再去解决上面小伙伴提出的问题,就是入队时,队列满了要等待,出队时,队列空了要等待,这个要怎么解决呢?这里要用的
wait()

notifyAll()
了,再进行编码前,我们先理清一下思路,

  1. 目前队列的长度是5,并且已经满了;
  2. 现在要向队列插入第6个元素,插入时,判断队列满了,要进行等待
    wait()
    ;
  3. 此时有一个出队操作,队列有空位了,此时应该唤起之前等待的线程,插入元素;

相反的,出队时,队列是空的,也要等待,当队列有元素时,唤起等待的线程,进行出队操作。好了,撸代码,

/**
 * 入队操作
 * @param e
 * @return
 */
public synchronized boolean add(T e) throws InterruptedException {
    if (isFull()) {
        wait();
    }
    data[tail] = e;
    tail = (tail + 1) % size;
    if (tail == front) {
        flag = 1;
    }
    notifyAll();
    return true;
}

/**
 * 出队操作
 * @return
 */
public synchronized T poll() throws InterruptedException {
    if (isEmpty()) {
        wait();
    }
    T rtnData = data[front];
    front = (front + 1) % size;
    if (front == tail) {
        flag = 0;
    }
    notifyAll();
    return rtnData;
}

之前我们抛异常的地方,统一改成了
wait()
,而且方法执行到最后进行
notifyAll()
,唤起等待的线程。我们进行简单的测试,

public static void main(String[] args) throws InterruptedException {
    MyQueue<String> myQueue = new MyQueue<>(5);
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();

    myQueue.add("a");
}

测试结果没有问题,可以正常打印"a"。这里只进行了出队的等待测试,入队的测试,小伙伴们自己完成吧。

if还是while

到这里,我们手撸的消息队列还算不错,基本的功能都实现了,但是有没有什么问题呢?我们看看下面的测试程序,

public static void main(String[] args) throws InterruptedException {
    MyQueue<String> myQueue = new MyQueue<>(5);
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();

    Thread.sleep(5000);
    myQueue.add("a");
}

我们启动了两个消费者线程,同时从队列里获取数据,此时,队列是空的,两个线程都进行等待,5秒后,我们插入元素"a",看看结果如何,

a
null

结果两个消费者都打印出了日志,一个获取到null,一个获取到”a“,这是什么原因呢?还记得我们怎么判断空和满的吗?对了,使用的是
if
,我们捋一下整体的过程,

  1. 两个消费者线程同时从队列获取数据,队列是空的,两个消费者通过
    if
    判断,进入等待;
  2. 5秒后,向队列中插入"a"元素,并唤起所有等待线程;
  3. 两个消费者线程被依次唤起,一个取到值,一个没有取到。没有取到是因为取到的线程将front加了1导致的。这里为什么说依次唤起等待线程呢?因为
    notifyAll()
    不是同时唤起所有等待线程,是依次唤起,而且顺序是不确定的。

我们希望得到的结果是,一个消费线程得到”a“元素,另一个消费线程继续等待。这个怎么实现呢?对了,就是将判断是用到的
if
改为
while
,如下:

/**
 * 入队操作
 * @param e
 * @return
 */
public synchronized boolean add(T e) throws InterruptedException {
    while (isFull()) {
        wait();
    }
    data[tail] = e;
    tail = (tail + 1) % size;
    if (tail == front) {
        flag = 1;
    }
    notifyAll();
    return true;
}

/**
 * 出队操作
 * @return
 */
public synchronized T poll() throws InterruptedException {
    while (isEmpty()) {
        wait();
    }
    T rtnData = data[front];
    front = (front + 1) % size;
    if (front == tail) {
        flag = 0;
    }
    notifyAll();
    return rtnData;
}

在判断空还是满的时候,我们使用
while
去判断,当两个消费线程被依次唤起时,还会再进行空和满的判断,这时,第一个消费线程判断队列中有元素,会进行获取,第二个消费线程被唤起时,判断队列没有元素,会再次进入等待。我们写段代码测试一下,

public static void main(String[] args) throws InterruptedException {
    MyQueue<String> myQueue = new MyQueue<>(5);
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();
    new Thread(() -> {
        try {
            System.out.println(myQueue.poll());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }).start();

    Thread.sleep(5000);
    myQueue.add("a");
    Thread.sleep(5000);
    myQueue.add("b");
}

同样,有两个消费线程去队列获取数据,此时队列为空,然后,我们每隔5秒,插入一个元素,看看结果如何,

a
b

10秒过后,插入的两个元素正常打印,说明我们的队列没有问题。入队的测试,大家自己进行吧。

总结

好了,我们手撸的消息队列完成了,看看都有哪些重点吧,

  1. 循环数组;
  2. 数组下标的计算,用取模法;
  3. 队列空与满的判断,注意flag;
  4. 并发;
  5. 唤起线程注意使用
    while

世界那么大,我想去看看...

原文

不会打歌么学打歌阿哥怎摆你怎摆,大江大海江大海 ...

瞧,这个中年不油腻(不油腻的原因是大叔很穷)的大叔扛着音箱出场了,其实远没有这么拉风!

今年被动看到许多不好的消息和内容:充满了“失业”,“裁员”等。一度我已经更郁郁了。所以我今天不是来搞笑的。真心希望大家能越来越好,希望看到大家报喜的内容来抵消一下。

园子里tobin老兄说:

琢磨了几条路,不知道能不能走通: 回家继承家业 ** 小说里的情节,回家继承千万家产,过上富足的生活?现实是,家里唯一值钱的老母猪都卖了,子承父业怕是只能喝西北风。 合伙创业,攒个小公司接项目 ** 几个朋友联合起来,先有个能撑3-5个月的项目做,倒腾倒腾,未必不能成。说实话,有明确项目来源是关键,不然很容易陷入资金链困局。 技术变现 ** 把技术做成产品,比如中间件、报表系统或BI工具,卖版本赚钱? ——高手或许能成,但多数人缺少资源和市场,难度不小。 转行做实业,开店或做餐饮,什么商店,五金,酒店,饭店 ** 看起来容易,但每行都得交学费。如果有人带着,可能会少走些弯路。实业不是表面看着那么简单,稍有不慎,亏掉的可能是你所有的积蓄和时间。

大叔我其实也和大伙差不多,整理了一下自己的情况发现并不是有什么清新脱俗的地方。但是我已经45周岁也就是虚岁46了,这是千真万确的事。我搞过收银软件,供应链管理,MES,现在在实施ERP。我曾经也编码20多年。很多人说我很正力量,我身上的标签有:“自学成才”,“中专学历”,“非著名野生程序员”等。但我一直都在小公司里苟且偷生,出来没在开发人员超过20的公司工作过。我的工资最高是2016年的16000。这么多年也只能是勉强养家糊口。10年前我在博客园写了一篇文章:
五有老码农,程序人生回顾:心安也不是归处啊

。又过了10年,我感觉还是没有改变太多。
整理了一下自己的情况发现并不是有什么清新脱俗的地方。但是我已经45周岁也就是虚岁46了,这是千真万确的事。我搞过收银软件,供应链管理,MES,现在在实施ERP。我曾经也编码20多年。很多人说我很正力量,我身上的标签有:“自学成才”,“中专学历”,“非著名野生程序员”等。但我一直都在小公司里苟且偷生,出来没在开发人员超过20的公司工作过。我的工资最高是2016年的16000。这么多年也只能是勉强养家糊口。10年前我在博客园写了一篇文章:
五有老码农,程序人生回顾:心安也不是归处啊

在国外这一年多我还完了欠亲戚的十几万外账,这是目前最欣慰的结果。但是我已经几个月都没有在写代码了,刚开始公司想让我做全职开发,让我一个一个子系统的上线。所以我开发了POS系统,并成功通过(萨摩亚和斐济的税务平台TaxCore)的备案。但后来公司选择使用开源ERP----ODOO并在广州选择了实施和二次开发的软件公司,这样我的开发工作就终止了,现在在仓库工作,参与系统维护。

我写这篇文章的原因是在博客园看到一篇文章“
41岁的大龄程序员,苟着苟着,要为以后做打算了
”,若有所思,有一点感慨不吐不快。

我其实是想对各位大龄码农说:世界很大,不要把自己局限在中国,有条件出国来看看吧。我朋友圈里最光鲜的案例应该是杨中科老师,他去新西兰读了硕士,目前已经全家移民了,得到了新西兰的永居身份。后来进了新西兰一国家银行工作,一周可以三天在家远程工作,资本主义国家都信奉“工作只是生活的辅助”,真是羡煞旁人啊。

我最近在努力提高英语能力,虽然学了那么多年的英语,但我还是不能达到流利和老外交流的水平。我发现自己口齿不清,词汇量也达不到。随便说一句杨中科老师也开发了一个学英语的网站,也有微信小程序端,大家请搜索youzack。因为上面的很多资源我其实还听不懂,所以我目前还没有用他这个。我目前在用多邻国App,已经连续坚持了230天。目前只是觉得听力有进步。最近准备多看一些美剧,我收集了一些高频使用的口语,大家听过没有?

None of this...

音标:/nʌn ʌv ðɪs/

中文注释:这些都不(不适用、不成立等)

使用场景:你在讨论一个话题,觉得所说的内容都不相关时可以说。例如:“None of this is relevant to the main issue.”

There is no shame in...

音标:/ðɛr ɪz noʊ ʃeɪm ɪn/

中文注释:...没有什么可羞愧的

场景: 你看到一个朋友因为失业而感到羞愧,你想要安慰他。

你可以说:“There is no shame in losing your job. Many people go through it, and it's just a part of life. What’s important is how you move forward.”

You'd never guess who...

音标:/juːd ˈnɛvər ɡɛs huː/

中文注释:你永远猜不到是谁

使用场景:你要揭示一个令人惊讶的人物时可以使用。例如:“You’d never guess who showed up at the party.”

All the way across town...

音标:/ɔːl ðə weɪ əˈkrɔːs taʊn/

中文注释:整个城市的另一边

使用场景:当你谈论一个地点很远时可以使用。例如:“I had to drive all the way across town to get there.”

I am not cut out for...

音标:/aɪ æm nɒt kʌt aʊt fɔːr/

中文注释:我不适合

使用场景:你在表示自己不适合某项工作或任务时。例如:“I’m not cut out for this kind of high-pressure job.”

Mooching off of...

音标:/ˈmuːtʃɪŋ ɒf ʌv/

中文注释:依赖于(别人的钱或资源)

使用场景:当你觉得某人依赖别人而不自食其力时。例如:“He’s just mooching off of his parents.”

Wanna grab...

音标:/ˈwɑːnə ɡræb/

中文注释:想去拿(或去吃、喝)

使用场景:你提议去吃饭或喝东西时。例如:“Wanna grab a coffee later?”

Beat around the bush...

音标:/biːt əˈraʊnd ðə bʊʃ/

中文注释:拐弯抹角

使用场景:当你要说某人说话不直接时可以用。例如:“Stop beating around the bush and tell me what you really think.”

Why is this so...

音标:/waɪ ɪz ðɪs səʊ/

中文注释:为什么会这样

使用场景:你对某事的原因感到困惑时可以使用。例如:“Why is this so difficult to understand?”

Patch things up...

音标:/pætʃ θɪŋz ʌp/

中文注释:修补关系

使用场景:当你要修复破裂的关系或解决争端时。例如:“Let’s patch things up and move on.”

Take the word of...

音标:/teɪk ðə wɜːrd ʌv/

中文注释:相信某人的话

使用场景:当你决定相信某人所说的话时。例如:“I’ll take your word for it.”

I wouldn't even know...

音标:/aɪ ˈwʊdnt ˈiːvn noʊ/

中文注释:我甚至不知道

使用场景:当你对某事一无所知时可以使用。例如:“I wouldn’t even know how to start fixing that.”

I should probably...

音标:/aɪ ʃʊd ˈprɒbəbli/

中文注释:我可能应该

使用场景:当你考虑某个动作是对的时可以使用。例如:“I should probably call her and apologize.”

I bid you adieu...

音标:/aɪ bɪd juː əˈdjuː/

中文注释:我向你道别

使用场景:你在正式告别或结束交流时可以使用。例如:“I bid you adieu and wish you safe travels.”

Have a knack for...

音标:/hæv ə næk fɔːr/

中文注释:有天赋

使用场景:当你形容某人在某方面有特别的才能时可以使用。例如:“She has a knack for solving difficult problems.”

Wanna go grab...

音标:/ˈwɒnə ɡoʊ ɡræb/

中文注释:想去吃(或喝)

使用场景:当你想提议去外面吃东西时可以使用。例如:“Wanna go grab dinner after work?”

I could really go for...

音标:/aɪ kʊd ˈrɪəli ɡoʊ fɔːr/

中文注释:我真的很想要

使用场景:当你特别想要某样食物或饮品时可以使用。例如:“I could really go for a slice of pizza right now.”

what are you gonna...

音标: /wɑːt ɑːr juː ˈɡənə dʊ/ 或 /wɑːt ɑːr juː ˈɡənə seɪ/

中文注释: 你打算做什么?/你打算说什么?

使用场景: 你和朋友在商量周末的安排,你可以问:“What are you gonna do this weekend?” (你这个周末打算做什么?)

询问意图: 当对方做了一些让你困惑的事情时,你可能会问:“What are you gonna say about this?” (你对此打算说什么?)

你愿意和我一起提高自己的英语水平吗,记得我是你的伙伴哟!苦于想找人对练口语好久了,本来我用AI也就是chatGPT来练的,但是一般情况他都听不懂我说的,是的我口齿不清,我估计他会太无聊,于是就取消了。

原创文章,欢迎转载,转载请注明出处,谢谢。


0. 前言


第四讲
我们介绍了 main goroutine 是如何运行的。其中针对 main goroutine 介绍了调度函数 schedule 是怎么工作的,对于整个调度器的调度策略并没有介绍,这点是不完整的,这一讲会完善调度器的调度策略部分。

1. 调度时间点

runtime.schedule
实现了调度器的调度策略。那么对于调度时间点,查看哪些函数调用的
runtime.schedule
即可顺藤摸瓜理出调度器的调度时间点,如下图:

image

调度时间点不是本讲的重点,这里有兴趣的同学可以顺藤摸瓜,摸摸触发调度时间点的路径,这里就跳过了。

2. 调度策略

调度策略才是我们的重点,进到
runtime.schedule

// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
    mp := getg().m                  // 获取当前执行线程

top:
	pp := mp.p.ptr()                // 获取执行线程绑定的 P
	pp.preempt = false

    // Safety check: if we are spinning, the run queue should be empty.
	// Check this before calling checkTimers, as that might call
	// goready to put a ready goroutine on the local run queue.
    if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
		throw("schedule: spinning with local work")
	}

    gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

    ...
    execute(gp, inheritTime)        // 执行找到的 goroutine
}

runtime.schedule
的重点在
findRunnable()

findRunnable()
函数很长,为避免影响可读性,这里对大部分流程做了注释,后面在有重点的加以介绍。进入
findRunnable()

// Finds a runnable goroutine to execute.
// Tries to steal from other P's, get g from local or global queue, poll network.
// tryWakeP indicates that the returned goroutine is not normal (GC worker, trace
// reader) so the caller should try to wake a P.
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
	mp := getg().m                                      // 获取当前执行线程

top:
	pp := mp.p.ptr()                                    // 获取线程绑定的 P
	...
	
    // Check the global runnable queue once in a while to ensure fairness.
	// Otherwise two goroutines can completely occupy the local runqueue
	// by constantly respawning each other.
	if pp.schedtick%61 == 0 && sched.runqsize > 0 {
		lock(&sched.lock)
		gp := globrunqget(pp, 1)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}

    // local runq
	if gp, inheritTime := runqget(pp); gp != nil {      // 从 P 的本地队列中获取 goroutine
		return gp, inheritTime, false
	}

    // global runq
	if sched.runqsize != 0 {                            // 如果本地队列获取不到就判断全局队列中有无 goroutine
		lock(&sched.lock)                               // 如果有的话,为全局变量加锁
		gp := globrunqget(pp, 0)                        // 从全局队列中拿 goroutine
		unlock(&sched.lock)                             // 为全局变量解锁
		if gp != nil {
			return gp, false, false
		}
	}

    // 如果全局队列中没有 goroutine 则从 network poller 中取 goroutine
    if netpollinited() && netpollWaiters.Load() > 0 && sched.lastpoll.Load() != 0 {
		...
	}

    // 如果 network poller 中也没有 goroutine,那么尝试从其它 P 中偷 goroutine
    // Spinning Ms: steal work from other Ps.
	//
	// Limit the number of spinning Ms to half the number of busy Ps.
	// This is necessary to prevent excessive CPU consumption when
	// GOMAXPROCS>>1 but the program parallelism is low.
    // 如果下面两个条件至少有一个满足,则进入偷 goroutine 逻辑
    // 条件 1: 当前线程是 spinning 自旋状态
    // 条件 2: 当前活跃的 P 要远大于自旋的线程,说明需要线程去分担活跃线程的压力,不要睡觉了
	if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
        if !mp.spinning {                                       // 因为是两个条件至少满足一个即可,这里首先判断当前线程是不是自旋状态
			mp.becomeSpinning()                                 // 如果不是,更新线程的状态为自旋状态
		}

        gp, inheritTime, tnow, w, newWork := stealWork(now)     // 偷 goroutine
		if gp != nil {
			// Successfully stole.
			return gp, inheritTime, false                       // 如果 gp 不等于 nil,表示偷到了,返回偷到的 goroutine
		}
		if newWork {                
			// There may be new timer or GC work; restart to
			// discover.
			goto top                                            // 如果 gp 不等于 nil,且 network 为 true,则跳到 top 标签重新找 goroutine
		}

		now = tnow
		if w != 0 && (pollUntil == 0 || w < pollUntil) {
			// Earlier timer to wait for.
			pollUntil = w
		}
	}

    ...
    if sched.runqsize != 0 {                                    // 偷都没偷到,还要在找一遍全局队列,防止偷的过程中,全局队列又有 goroutine 了
		gp := globrunqget(pp, 0)
		unlock(&sched.lock)
		return gp, false, false
	}

    if !mp.spinning && sched.needspinning.Load() == 1 {         // 在判断一遍,如果 mp 不是自旋状态,且 sched.needspinning == 1 则更新 mp 为自旋,调用 top 重新找一遍 goroutine
		// See "Delicate dance" comment below.
		mp.becomeSpinning()
		unlock(&sched.lock)
		goto top
	}

    // 实在找不到 goroutine,表明当前线程多, goroutine 少,准备挂起线程
    // 首先,调用 releasep 取消线程和 P 的绑定
    if releasep() != pp {                                       
		throw("findrunnable: wrong p")
	}

    ...
    now = pidleput(pp, now)                                     // 将解绑的 P 放到全局空闲队列中
    unlock(&sched.lock)

    wasSpinning := mp.spinning                                  // 到这里 mp.spinning == true,线程处于自旋状态
	if mp.spinning {
		mp.spinning = false                                     // 设置 mp.spinning = false,这是要准备休眠了
		if sched.nmspinning.Add(-1) < 0 {                       // 将全局变量的自旋线程数减 1,因为当前线程准备休眠,不偷 goroutine 了
			throw("findrunnable: negative nmspinning")
		}
        ...
    }
    stopm()                                                     // 线程休眠,直到唤醒
	goto top                                                    // 能执行到这里,说明线程已经被唤醒了,继续找一遍 goroutine
}

看完线程的调度策略我都要被感动到了,何其的敬业,穷尽一切方式去找活干,找不到活,休眠之前还要在找一遍,真的是劳模啊。

大致流程是比较清楚的,我们把其中一些值得深挖的部分在单拎出来。

首先,从本地队列中找 goroutine,如果找不到则进入全局队列找,这里如果看
gp := globrunqget(pp, 0)
可能会觉得疑惑,从全局队列中拿 goroutine 为什么要把 P 传进去,我们看这个函数在做什么:

// Try get a batch of G's from the global runnable queue.
// sched.lock must be held.											// 注释说的挺清晰了,把全局队列的 goroutine 放到 P 的本地队列
func globrunqget(pp *p, max int32) *g {
	assertLockHeld(&sched.lock)										

	if sched.runqsize == 0 {
		return nil
	}

	n := sched.runqsize/gomaxprocs + 1								// 全局队列是线程共享的,这里要除 gomaxprocs 平摊到每个线程绑定的 P
	if n > sched.runqsize {
		n = sched.runqsize											// 执行到这里,说明 gomaxprocs == 1
	}
	if max > 0 && n > max {
		n = max
	}
	if n > int32(len(pp.runq))/2 {									
		n = int32(len(pp.runq)) / 2									// 如果 n 比本地队列长度的一半要长,则 n == len(P.runq)/2
	}

	sched.runqsize -= n												// 全局队列长度减 n,准备从全局队列中拿 n 个 goroutine 到 P 中

	gp := sched.runq.pop()											// 把全局队列队头的 goroutine 拿出来,这个 goroutine 是要返回的 goroutine
	n--																// 拿出了一个队头的 goroutine,这里 n 要减 1
	for ; n > 0; n-- {				
		gp1 := sched.runq.pop()										// 循环拿全局队列中的 goroutine 出来
		runqput(pp, gp1, false)										// 将拿出的 goroutine 放到全局队列中
	}
	return gp
}

调用
globrunqget
说明本地队列没有 goroutine 要从全局队列拿,那么就可以把全局队列中的 goroutine 放到 P 中,提高了全局队列 goroutine 的优先级。

如果全局队列也没找到 goroutine,在从
network poller
找,如果
network poller
也没找到,则准备进入自旋,从别的线程的 P 那里偷活干。我们看线程是怎么偷活的:

// stealWork attempts to steal a runnable goroutine or timer from any P.
//
// If newWork is true, new work may have been readied.
//
// If now is not 0 it is the current time. stealWork returns the passed time or
// the current time if now was passed as 0.
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
	pp := getg().m.p.ptr()																// pp 是当前线程绑定的 P

	ranTimer := false

	const stealTries = 4																// 线程偷四次,每次都要随机循环一遍所有 P
	for i := 0; i < stealTries; i++ {
		stealTimersOrRunNextG := i == stealTries-1

		for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {			// 为保证偷的随机性,随机开始偷 P。随机开始,后面每个 P 都可以轮到
			...
			p2 := allp[enum.position()]													// 从 allp 中获取 P
			if pp == p2 {
				continue																// 如果获取的是当前线程绑定的 P,则继续循环下一个 P
			}
			...
			// Don't bother to attempt to steal if p2 is idle.
			if !idlepMask.read(enum.position()) {										// 判断拿到的 P 是不是 idle 状态,如果是,表明 P 还没有 goroutine,跳过它,偷下一家
				if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {			// P 不是 idle,调用 runqsteal 偷它!
					return gp, false, now, pollUntil, ranTimer
				}
			}
		}
	}

	// No goroutines found to steal. Regardless, running a timer may have
	// made some goroutine ready that we missed. Indicate the next timer to
	// wait for.
	return nil, false, now, pollUntil, ranTimer
}

线程随机的偷一个可偷的 P,偷 P 的实现在
runqsteal
,查看
runqsteal
怎么偷的:

// Steal half of elements from local runnable queue of p2
// and put onto local runnable queue of p.
// Returns one of the stolen elements (or nil if failed).						// 给宝宝饿坏了,直接偷一半的 goroutine 啊,够狠的!
func runqsteal(pp, p2 *p, stealRunNextG bool) *g {
	t := pp.runqtail															// t 指向当前 P 本地队列的队尾
	n := runqgrab(p2, &pp.runq, t, stealRunNextG)								// runqgrab 把 P2 本地队列的一半 goroutine 拿到 P 的 runq 队列中
	if n == 0 {
		return nil
	}
	n--
	gp := pp.runq[(t+n)%uint32(len(pp.runq))].ptr()								// 把偷到的本地队列队尾的 goroutine 拿出来
	if n == 0 {
		return gp																// 如果只偷到了这一个,则直接返回。有总比没有好
	}
	h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with consumers
	if t-h+n >= uint32(len(pp.runq)) {
		throw("runqsteal: runq overflow")										// 如果 t-h+n >= len(p.runq) 表示偷多了...
	}
	atomic.StoreRel(&pp.runqtail, t+n) 											// 更新 P 的本地队列的队尾
	return gp
}

这个偷就是把“地主家”(P2)的余粮 (goroutine) 给它抢一半过来,没办法我也要吃饭啊。

如果连偷都没偷到(好吧,太惨了点...),那就准备休眠了,不干活了还不行嘛。不干活之前在去看看全局队列有没有 goroutine 了(口是心非的 M 人)。还是没活,好吧,准备休眠了。

准备休眠,首先解除和 P 的绑定:

func releasep() *p {
	gp := getg()

	if gp.m.p == 0 {
		throw("releasep: invalid arg")
	}
	pp := gp.m.p.ptr()
	if pp.m.ptr() != gp.m || pp.status != _Prunning {
		print("releasep: m=", gp.m, " m->p=", gp.m.p.ptr(), " p->m=", hex(pp.m), " p->status=", pp.status, "\n")
		throw("releasep: invalid p state")
	}
	...
	gp.m.p = 0
	pp.m = 0
	pp.status = _Pidle
	return pp
}

就是指针的解绑操作,代码很清晰,连注释都不用,我们也不讲了。

解绑之后,
pidleput
把空闲的 P 放到全局空闲队列中。

接着,更新线程的状态,从自旋更新为非自旋,调用
stopm
准备休眠:

// Stops execution of the current m until new work is available.
// Returns with acquired P.
func stopm() {
	gp := getg()							// 当前线程执行的 goroutine

	...

	lock(&sched.lock)
	mput(gp.m)								// 将线程放到全局空闲线程队列中
	unlock(&sched.lock)
	mPark()
	acquirep(gp.m.nextp.ptr())
	gp.m.nextp = 0
}

stopm
将线程放到全局空闲线程队列,接着调用
mPark
休眠线程:

// mPark causes a thread to park itself, returning once woken.
//
//go:nosplit
func mPark() {
	gp := getg()
	notesleep(&gp.m.park)					// notesleep 线程休眠
	noteclear(&gp.m.park)
}

func notesleep(n *note) {
	gp := getg()
	if gp != gp.m.g0 {
		throw("notesleep not on g0")
	}
	ns := int64(-1)
	if *cgo_yield != nil {
		// Sleep for an arbitrary-but-moderate interval to poll libc interceptors.
		ns = 10e6
	}
	for atomic.Load(key32(&n.key)) == 0 {					// 这里通过 n.key 判断线程是否唤醒,如果等于 0,表示未唤醒,线程继续休眠
		gp.m.blocked = true
		futexsleep(key32(&n.key), 0, ns)					// 调用 futex 休眠线程,线程会“阻塞”在这里,直到被唤醒
		if *cgo_yield != nil {
			asmcgocall(*cgo_yield, nil)
		}
		gp.m.blocked = false								// “唤醒”,设置线程的 blocked 标记为 false
	}
}

// One-time notifications.
func noteclear(n *note) {									
	n.key = 0												// 执行到 noteclear 说明,线程已经被唤醒了,这时候线程重置 n.key 标志位为 0
}

线程休眠是通过调用
futex
进入操作系统内核完成线程休眠的,关于
futex
的内容可以参考
这里

线程的 n.key 是休眠的标志位,当 n.key 不等于 0 时表示有线程在唤醒休眠线程,线程从休眠状态恢复到正常状态。唤醒休眠线程通过调用
notewakeup(&nmp.park)
函数实现:

func notewakeup(n *note) {
	old := atomic.Xchg(key32(&n.key), 1)
	if old != 0 {
		print("notewakeup - double wakeup (", old, ")\n")
		throw("notewakeup - double wakeup")
	}
	futexwakeup(key32(&n.key), 1)					// 调用 futexwakeup 唤醒休眠线程
}

首先,线程是怎么找到休眠线程的?线程通过全局空闲线程队列找到空闲的线程,并且将空闲线程的休眠标志位 m.park 传给
notewakeup
,最后调用
futexwakeup
唤醒休眠线程。

值得一提的是,唤醒的线程在唤醒之后还是会继续找可运行的 goroutine 直到找到:

func stopm() {
	...
	mPark()								// 如果 mPark 返回,表示线程被唤醒,开始正常工作
	acquirep(gp.m.nextp.ptr())			// 前面休眠前,线程已经和 P 解绑了。这里在给线程找一个 P 绑定
	gp.m.nextp = 0						// 线程已经绑定到 P 了,重置 nextp
}

基本这就是调度策略中很重要的一部分,线程如何找 goroutine。找到 goroutine 之后调用
gogo
执行该 goroutine。

3. 小结

本讲继续丰富了调度器的调度策略,下一讲,我们开始非 main goroutine 的介绍。


前言

人工智能时代,人脸识别技术已成为安全验证、身份识别和用户交互的关键工具。

给大家推荐一款.NET 开源提供了强大的人脸识别 API,工具不仅易于集成,还具备高效处理能力。

本文将介绍一款如何利用这些API,为我们的项目添加智能识别的亮点。

项目介绍

GitHub 上拥有 1.2k 星标的 C# 面部识别 API 项目:FaceRecognitionDotNet。该项目功能强大,开箱即用,并支持跨平台。

它使用了 OpenCVSharp 和 face_recognition 开源库,并提供了 NuGet 包,方便集成到项目中。

项目是 face_recognition 的 C# 移植版本。 face_recognition 是一个基于 Python 的人脸识别库,它提供了简单易用的接口来进行人脸检测、人脸识别和人脸特征提取等功能。

这个库基于dlib和OpenCV开发,并且提供了一个高级的人脸识别接口,可以用于识别图像或视频中的人脸,并且可以识别出不同人物之间的相似度。

项目特点

  • 预测年龄
  • 情绪识别
  • 性别判断
  • 脸部标记
  • 眨眼检测

项目说明

支持跨平台,包括 Windows、Linux 和 macOS!

支持的API

项目展示

1、面部识别

2、年龄和性别

3、脸部标记

4、情绪识别

项目地址

文档:
https://taktak.jp/FaceRecognitionDotNet/

face_recognition:
https://github.com/ageitgey/face_recognition

Github:
https://github.com/takuya-takeuchi/FaceRecognitionDotNet

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!