go语言并发编程

操作系统是我们系统资源的"管理者"。它决定着哪些进程可以在何时使用各种系统资源,包括处理时间、内存和网络资源等。作为开发者,我们不必一定精通操作系统的内部原理。不过,我们需要了解其运作方式,以及它所提供的工具,这样才能更轻松地完成编程工作。

操作系统中的多处理技术

操作系统是如何通过抽象机制来支持并发程序的运行的呢?所谓"多处理",也就是指操作系统能够同时处理多个任务。这一点非常重要,因为它让我们能够更有效地利用CPU资源。当CPU处于空闲状态时,比如当前任务正在等待用户输入时,操作系统就可以让CPU去执行其他任务了。

每当我们在一个系统中执行某项任务时,无论是在家用笔记本电脑上还是云服务器上,该任务的执行过程都会经历不同的状态。为了充分了解任务所经历的各个阶段,我们以一个例子来说明这些状态。假设我们在系统中运行一条命令,目的是在大型文本文件中查找特定的字符串。如果我们的系统是UNIX平台,那么我们可以使用如下命令:

go 复制代码
grep 'hello' largeReadme.md

单CPU系统的操作系统运行状态

让我们一步一步来,仔细看看这些状态中的每一个。

  1. 用户提交字符串搜索任务以进行执行。另一个程序请求进行软件中断处理。
  2. 操作系统会将这项任务放入任务队列中。当任务尚未准备好执行时,它就会被存入这个队列中。
  3. 一旦文本搜索功能准备就绪,它就会被放入待处理队列中。
  4. 在某个时刻,当CPU空闲下来时,操作系统会从就绪队列中选取相应的任务,并让CPU开始执行该任务。此时,处理器就会按照任务中所包含的指令来运行程序。
  5. 每当我们的文本搜索任务需要从文件中读取数据时,操作系统会将该任务从CPU中移除,将其放入I/O等待队列中。在该队列中,任务会一直等待,直到所需的I/O操作完成并返回数据为止。如果准备队列中有其他可执行的任务,操作系统就会选取其中的一个任务在CPU上执行,从而让处理器保持忙碌状态。
  6. 该设备将执行并完成输入/输出操作(从文本文件中读取若干字节的数据)。
  7. 一旦I/O操作完成,该任务就会重新回到等待队列中。此时,它需要等待操作系统的调度,以便能够继续执行。出现这种等待情况的原因是:CPU可能正忙于处理其他任务。
  8. 在某个时刻,CPU再次变得空闲起来,这时操作系统会重新接管文本搜索任务,并继续在CPU上执行相关指令。在这种情况下,这些指令通常是为了在从文件中读取的文本中查找匹配项。
  9. 此时,当任务正在执行过程中,系统可能会触发中断。所谓"中断",是一种用于停止当前任务的执行,并向系统通报某种特定事件的机制。负责处理来自多个设备的所有中断的硬件设备被称为"中断控制器"。该控制器会通知CPU停止当前任务,转而执行其他任务。通常,这种切换需要调用设备驱动程序或操作系统的调度功能来实现。触发中断的原因有很多种。
  10. I/O设备负责完成各种操作,比如读取文件、进行网络通信,或是处理键盘输入等操作。
  11. 硬件时钟(或计时器)发出信号,从而中断当前的执行过程。这样一来,就确保了等待队列中的其他任务也能获得执行的机会。
  12. 操作系统会暂停当前任务的执行,将其重新放入等待队列中。随后,操作系统会从等待队列中选取另一个任务,在CPU上对其进行处理。操作系统调度算法的任务,就是决定从等待队列中选择哪个任务来执行
  13. 在某个时刻,我们的任务会再次被操作系统调度器接手,然后在CPU上继续执行。根据文本文件的大小以及系统中同时运行的其他任务数量,步骤4到步骤10通常会重复执行多次。
  14. 我们的文本搜索已完成编程操作(即搜索过程已经结束),现在可以终止程序了。
    每次上下文切换时,都会产生一定的开销------操作系统需要保存当前任务的执行状态,以便日后能够从暂停处继续执行。同时,操作系统还需要加载下一个要执行的任务的执行状态。这种状态被称为进程上下文块(PCB)。进程上下文块是一种数据结构,用于存储与任务相关的所有信息,比如程序计数器、CPU寄存器以及内存相关信息等。
    这种上下文切换的效果是:即便我们只有一个CPU,也会让人觉得好像有多个任务在同时执行。当我们编写并发代码并在只有一个处理器的系统中运行它时,代码会创建出一组任务,这些任务以这种方式并行执行,从而实现更快的响应速度。而当系统拥有多个CPU时,就可以实现真正的并行处理,因为各个任务可以在不同的处理单元上同时进行运算。

利用进程和线程实现并发的抽象处理

当我们需要执行代码并处理并发问题时------也就是让多个任务能够同时运行或看起来像是同时运行------或者在多核系统中实现真正的并行处理时,操作系统提供了两种解决方案:进程和线程。

进程指的是当前在系统中运行的程序。它是操作系统中的一个重要概念。操作系统的核心作用,就是如何高效地将系统资源(如内存和CPU处理能力)分配给那些正在运行的各个进程。正如前一节所提到的,我们可以利用多个进程让它们同时运行。

线程是一种在进程上下文中运行的特殊机制,它让我们能够以更轻量级、更高效的方式实现并发处理。正如我们将要看到的,每个进程都始于一个执行线程,这个线程通常被称为主线程或主执行线程。在本节中,我们将探讨使用多个进程来实现并发与在单个进程中运行多个线程这两种方式的区别。

进程的创建

进程是一种抽象概念,它描述了系统执行代码的方式。如果我们希望让代码在隔离的环境中运行,那么就必须要告诉操作系统何时创建进程、以及应执行哪段代码。幸运的是,操作系统提供了相应的系统调用功能,让我们能够方便地创建、启动和管理进程。

例如,Windows系统中有一个名为CreateProcess()的系统调用。通过这个调用,可以创建一个进程,分配所需的资源,加载程序代码,然后让该程序作为独立进程开始运行。

或者,在UNIX系统中,有fork()系统调用。利用这个调用,我们可以创建一个进程的副本。当从正在执行的进程中调用此系统调用时,操作系统会复制该进程的内存空间以及所有资源管理机制,包括寄存器、堆栈、文件处理相关组件,甚至程序计数器等。之后,新创建的进程便能使用这些资源,从原来的执行点继续运行下去。

fork()系统调用会返回父进程的进程ID,而子进程则得到0作为返回值。当一个进程通过fork()分裂成两个进程后,每个进程都可以根据fork()调用的返回值来决定要执行哪些指令。子进程可以选择使用那些被复制的资源(比如内存中的数据),或者清除这些资源并重新开始。由于每个进程都有自己独立的内存空间,因此如果其中一个进程修改了其内存中的数据,另一个进程则不会看到这一变化。

可以想象,由于每个进程都有自己独立的内存空间,因此每次创建新进程时,系统消耗的总内存量就会增加。此外,复制和分配系统资源也需要时间,会占用宝贵的CPU资源。这意味着,创建过多的进程会给系统带来沉重的负担。正因如此,通常情况下,一个程序不会同时使用大量进程来处理同一个问题。

"写时复制"技术是一种针对fork()系统调用而设计的优化措施。通过这种方式,无需复制整个内存空间,从而节省了时间。在使用这种优化技术的系统中,每当调用fork()时,子进程和父进程会共享相同的内存页。如果其中一个进程试图修改某块内存的内容,那么该内存页会被复制到新的位置,这样每个进程就能拥有自己独立的内存副本。操作系统只会对那些被修改过的内存页进行复制。这种做法能够有效节省内存和计算资源。不过,如果某个进程修改了内存中的大量数据,那么操作系统最终还是需要复制大部分内存页的内容。

在Go语言中,创建和分离进程的功能主要依赖于syscal1包,而这些功能是特定于操作系统的。在Windows系统中,可以使用createProcess()函数来创建进程;而在UNIX系统中,则可以使用ForkExec()和startProcess()函数来实现相同的功能。此外,Go还允许我们通过调用exec()函数来在新进程中执行命令,从而避免了直接使用syscall包中那些与操作系统相关的函数。不过,Go中的并发编程通常不依赖复杂的进程结构。实际上,Go采用的是更为轻量级的线程和goroutine并发模型。

当一个进程执行完其代码或遇到无法处理的错误时,它就会终止。进程终止后,操作系统会回收其占用的所有资源,这些资源就可以被其他进程使用了。这些资源包括内存空间、打开的文件句柄、网络连接等等。在UNIX和Windows系统中,当父进程终止时,子进程并不会自动随之终止。

利用多进程处理常见任务

你有没有想过,当你执行这样的UNIX命令时,幕后究竟发生了什么?

bash 复制代码
$ curl -s https://www.rfc-editor.org/rfc/rfc1122.txt | wc

在UNIX系统中执行此命令时,命令行会创建两个并行运行的进程。我们可以通过打开另一个终端并运行"ps -a"命令来确认这一点。

bash 复制代码
PID TTY TIME CMD
. . .
26013 pts/49 00:00:00 curl
26014 pts/49 00:00:00 wc
· · · 

在这个例子中,第一个进程(进程ID为26013)会运行curl程序,该程序会从指定的URL下载文本文件。第二个进程(进程ID为26014)则负责统计文本中的单词数量。在这里,我们将第一个进程的输出结果通过缓冲区传递给第二个进程作为输入数据。通过使用管道操作符,我们可以让操作系统分配一个缓冲区,然后将curl进程的输出结果和单词计数进程的输入数据都导向这个缓冲区。当缓冲区满时,curl进程会暂停执行,而当单词计数进程处理完缓冲区中的数据后,curl进程会继续执行。同样地,当缓冲区为空时,单词计数进程也会暂停执行,直到curl进程再次将更多数据写入缓冲区为止。

一旦curl从网页中读取完所有文本后,它就会终止操作,并在管道中设置一个标记,表明不再有数据可读了。这个标记相当于向计数程序发出的信号,示意其可以停止工作了,因为不会再有新的数据输入。

线程并发处理

进程是解决并发问题的有效手段。它们能够实现良好的隔离效果,但同时也会消耗大量资源,而且创建进程需要一定的时间。

线程是为了解决使用进程进行并发处理时所面临的一些问题的有效手段。可以说,线程是多进程的轻量级替代方案。创建线程的速度要快得多(有时甚至快100倍),而且线程消耗的系统资源也比进程少。从概念上讲,线程可以被视为进程内部的另一种执行环境(有点像微型进程)。

让我们继续使用这个简单的比喻:想象我们是一群人一起画一幅画。与其让每个人各自拿着纸张独立作画,不如准备一块巨大的空白画布,然后给每个人分发画笔和铅笔。这样,大家就可以在同一个画布上共同作画了。

这其实与使用线程时的情况类似。比如在共享画布资源时,多个线程可以同时运行,从而共享同一块内存空间。这样一来,效率更高了,因为每次执行时都不需要占用大量内存。此外,共享内存空间意味着我们无需在最后再合并各个线程处理的结果。根据所解决的问题不同,通过与其它线程共享内存,我们往往能更高效地找到解决方案。

在讨论进程时,我们了解到,一个进程既包含各种资源(内存中的程序和数据),也包含负责执行该程序的机制。从概念上讲,我们可以将资源与执行过程分开来看待,因为这样处理更便于我们理解进程的运作方式。

可以创建多个执行线程,并让它们共享资源。我们把每个执行线程称为"线程"。默认情况下,启动一个进程时,其中会包含一个主线程。当一个进程中存在多个线程时,我们就说该进程是多线程进程。多线程编程指的是让不同的线程在同一应用程序中协同工作的编程方式。如图展示了两个线程如何共享同一个进程所拥有的内存。

当我们创建一个新线程时,操作系统只需分配足够的资源来管理堆栈空间、寄存器以及程序计数器即可。新线程在同一个进程的上下文中运行。而相比之下,当创建一个新进程时,操作系统需要为其分配全新的内存空间。正因如此,线程比进程更为轻量级;在系统资源耗尽之前,我们可以创建的线程数量远远多于进程的数量。此外,由于需要分配的资源较少,启动一个线程的速度也远远快于启动一个进程。

栈空间用于存储函数内部的局部变量。这些变量通常都是临时性的------当函数执行完毕后,它们就不再被使用了。需要注意的是,栈空间不包含那些在多个函数之间共享的变量,这类变量是通过指针来引用的,它们被存储在主内存空间中,也就是所谓的"堆"中。

这种额外的性能提升是有代价的。由于所有线程都在同一个内存空间中运行,因此无法像单独的进程那样实现隔离。这样一来,一个线程就有可能干扰到另一个线程的工作。为了避免这种情况,就需要确保多个线程之间能够进行有效的通信与同步。这就好比我们让一群画家共同完成一个项目、共享相同资源的情景:画家们需要彼此沟通,明确各自的任务和进度。如果没有这样的协作,就很可能出现互相覆盖对方作品的的情况,最终导致糟糕的结果。

由于线程共享内存空间,因此一个线程对主内存所做的任何修改(比如更改某个全局变量的值),都会被同一进程中的其他线程所感知到。这就是使用线程的最大优势:多个线程可以利用这种共享内存来共同处理同一个问题。这样一来,我们就能编写出高效且响应迅速的并发代码。

每当我们在一个函数中创建一个局部非共享变量时,这个变量就会被存储在栈空间中。因此,这些局部变量只能被创建它们的线程所访问。每个线程拥有自己的私有栈空间是非常重要的,因为不同线程可能会调用完全不同的函数,它们需要各自的私有空间来存储这些函数中使用的变量和返回值。

此外,每个线程还需要有自己的程序计数器。程序计数器(也称为指令指针)其实是指向CPU下一步要执行的指令的指针。由于各个线程通常会执行程序中不同的部分,因此每个线程都需要有独立的指令指针。

协程

goroutine既不是操作系统线程,也不是进程。Go语言的规范并没有严格规定goroutine的具体实现方式,不过目前的Go实现方式是将多组goroutine的执行任务分配给操作系统中的线程来处理。为了更好地理解这一点,我们先来了解一下另一种线程实现方式------用户级线程。

从操作系统的角度来看,包含用户级线程的进程看起来就只有一个执行线程。操作系统并不了解用户级线程的存在。管理、调度以及处理线程上下文等工作,都由进程本身来负责。

切换自身的用户级线程。要实现这种内部上下文切换,就需要有一个专门的运行时系统来管理一个表格,该表格记录着每个用户级线程的所有状态信息。实际上,我们是在进程的主线程中,以较小的规模模拟了操作系统在线程调度和管理方面的功能。

用户级线程的最大优势在于其出色的性能。切换用户级线程所需的开销远远小于切换内核级线程的开销。因为在进行内核级切换时,操作系统必须介入并决定下一个要执行的线程。而如果能够在不触发内核干预的情况下切换线程,那么执行中的进程就可以持续占用CPU资源,而不必刷新缓存,从而避免性能下降。

使用用户级线程的缺点在于:当这些线程执行那些需要等待I/O操作完成的代码时,整个进程的运行就会被中断。以从文件中读取数据为例:由于操作系统认为该进程只有一个执行线程,因此如果某个用户级线程执行了这种需要等待I/O操作的读取操作,那么整个进程就会暂停执行。如果该进程中还有其他用户级线程,它们也必须等待直到读取操作完成才能继续执行。这种情况显然不太理想,因为多线程的优势就在于:在其他线程等待I/O操作时,可以继续进行其他计算任务。为了解决这个问题,使用用户级线程的应用程序通常会采用非阻塞式I/O方式来处理I/O操作。不过,非阻塞式I/O也有其局限性,因为并非所有的设备都支持非阻塞式I/O功能。

用户级线程的另一个缺点是:在多处理器或多核系统中,任何时候我们都只能使用其中一个处理器来处理任务。操作系统将包含所有用户级线程的单一内核级线程视为一个独立的执行单元。因此,操作系统会在单个处理器上执行该内核级线程,而包含在该内核级线程中的用户级线程则无法真正实现并行执行。

Go语言提供了一种混合式架构:它能够让我们享受到用户级线程所带来的卓越性能,同时避免了其大部分缺点。实现这一目标的手段是使用多个内核级线程,每个线程负责管理一组goroutine。由于存在多个内核级线程,因此当有多个处理器可用时,程序就能充分利用这些处理器来提升运行效率。

为了说明这种混合式技术,假设我们的硬件只有两个处理器核心。我们可以使用这样的运行时系统:它创建并使用两个内核级线程,每个处理器核心对应一个内核级线程。每个内核级线程又可以管理若干个用户级线程。此时,操作系统会并行调度这两个内核级线程,让它们分别在不同的处理器上执行。这样,每个处理器上就能运行相应的用户级线程了。

M:N混合线程模型

Go语言中用于实现协程的机制,可以被称作M:N线程模型。在这种模式下,M个用户级线程对应N个内核级线程。这与普通的用户级线程有所不同,后者的模式被称为N:1线程模型,即N个用户级线程对应1个内核级线程。对于M:N线程模型的实现来说,其复杂性远远高于其他线程模型,因为需要多种技术来协调和管理用户级线程与内核级线程之间的关系。

Go语言的运行时系统会根据逻辑处理器的数量来确定应使用多少个内核级线程。这一数值是通过名为GomaxpRocs的环境变量来设定的。如果该变量未被设置,Go会通过查询操作系统来获取系统的CPU数量,并据此来确定GomaxpRocs的值。你可以通过执行以下代码来查看Go所检测到的处理器数量以及GomaxpRocs的当前值。

在线程B因I/O操作而阻塞时,处于内核级别的线程会接管其任务并继续执行。

为了解决调用被阻塞的问题,Go语言对所有可能造成阻塞的操作进行了处理,从而能够及时察觉到有内核级线程即将被取消调度。当这种情况发生时,Go会创建一个新的内核级线程(或者从线程池中重新使用一个空闲的线程),然后将goroutine队列中的任务分配给这个新线程。新线程会从队列中选取一个goroutine并开始执行它。而原来那个因为等待I/O操作而处于阻塞状态的线程,则会被操作系统取消调度。这样一来,即使有goroutine因调用而被阻塞,也不会导致整个本地goroutine队列陷入停滞状态。

在Go语言中,这种将goroutine从一个队列转移到另一个队列的机制被称为"工作窃取"。这种机制不仅会在goroutine执行阻塞操作时发挥作用。当各个队列中的goroutine数量不平衡时,Go也会利用这一机制来调整平衡。例如,如果某个LRQ队列为空,而内核级别的线程没有更多的goroutine可以执行,那么它就会从其他线程的队列中"窃取"任务来执行。这样一来,就能确保所有处理器都能有任务可做,从而避免在有大量任务需要处理时出现某些处理器空闲的情况。

锁定到内核级线程

在Go语言中,我们可以通过调用runtime.LockosThread()函数,强制让某个goroutine与操作系统中的某个线程绑定在一起。这样一来,该goroutine就只能在该内核级线程上运行了。在其他goroutine调用runtime.UnlockosThread()之前,不会有其他goroutine能在同一个操作系统线程上运行。

当我们需要对内核级线程进行精细控制时,就可以使用这些功能。例如,在与外部C库进行交互时,我们必须确保goroutine不会切换到另一个内核级线程上,从而避免出现访问该库时的问题。

安排协程的执行顺序

当某个内核级线程在CPU上运行了足够长的时间后,操作系统调度器会从运行队列中选择下一个线程来继续执行。这种调度方式被称为"抢占式调度"。其实现原理是通过时钟中断来中断正在执行中的内核级线程,进而调用操作系统调度器。由于中断操作仅涉及操作系统调度器,而运行在用户空间中的Go调度器则需要依靠另一种机制来实现调度功能。

Go调度器需要通过执行特定的操作来完成上下文切换。因此,它依赖于用户级事件来触发自身的执行过程。这些事件包括启动新的goroutine(使用"go"关键字)、进行系统调用(比如读取文件内容),或是实现goroutine之间的同步。

我们也可以在代码中调用Go调度器,从而让调度器将控制权切换到另一个goroutine上。在并发编程中,这种操作通常被称为"让出控制权"的指令。也就是说,当前线程主动放弃对CPU的控制权,让其他线程有机会使用CPU。在下面的代码示例中,我们在main() goroutine中直接使用runtime.Gosched()来调用调度器。

go 复制代码
package main

import (
  "fmt"
  "runtime"
)

/*
Note: this program is an example of what not to do; using go scheduler
to synchronize executions
*/
func sayHello() {
  fmt.Println("Hello")
}

func main() {
  go sayHello()
  runtime.Gosched()
  fmt.Println("Finished")
}

如果不直接调用调度器,那么几乎不可能让sayHello()函数被执行。main()协程会在调用sayHello()函数的协程有时间在CPU上运行之前就终止了。由于在Go语言中,当main()协程终止时,整个进程也会随之结束,所以我们根本无法看到"Hello"这个字符串被打印出来。

警告:我们无法控制调度器会选择哪个协程来执行。当我们调用Go调度器时,它可能会选择另一个协程来执行,或者继续执行原本就由该协程负责的代码。

调度器很可能会再次选择执行main()协程,这样一来,我们就永远无法看到"Hello"这条消息了。实际上,通过在代码中调用runtime.Gosched()函数,我们只是增加了sayHello()被执行的概率而已。并不能保证它一定会被执行。

与操作系统的调度器一样,我们也无法预测Go语言的调度器下一步会执行什么操作。作为编写并发程序的程序员,我们绝不能编写那些依赖于某种固定调度顺序的代码,因为下次运行程序时,任务的执行顺序很可能会发生变化。如果我们想控制线程的执行顺序,那就必须在代码中加入同步机制,而不能依赖调度器来处理。

并发与并行之别

许多开发人员将"并发"和"并行"这两个术语混为一谈,有时把它们视为同一个概念。不过,很多教科书都明确区分了这两者之间的区别。

我们可以将"并发性"视为程序代码的一种属性,而"并行性"则是指正在执行的程序所具备的特性。当我们将程序中的指令划分成不同的任务,并明确界定各任务的边界以及同步点时,就实现了并发编程。以下是一些这样的任务的例子:

• 处理一个用户的请求。

• 在某个文件中搜索指定的文本内容。

• 计算矩阵乘法中某一行对应的运算结果。

• 渲染出一帧电子游戏画面。

这些任务可以并行执行,也可以不并行执行。是否能够并行执行取决于程序运行的硬件环境和条件。例如,如果我们的并行矩阵乘法程序在多核系统上运行,那么我们就可以同时处理多行数据。要想实现并行执行,就需要多个处理单元。否则,系统可以通过交替执行各任务的方式来让用户觉得它正在同时处理多项任务。比如,两个线程可以轮流使用同一个处理器,各自占用一定的处理时间。由于操作系统能快速切换线程,因此看起来这两个线程就像是在同时运行一样。

注意:并发性指的是如何同时处理多项任务的规划方式。

并行处理指的是同时执行多项任务。

显然,这些定义之间存在重叠之处。实际上,我们可以认为"并行性"是"并发性"的一个子集。只有那些具备并发特性的程序才能实现并行执行,但并非所有的并发程序都能被并行执行。

如果我们只有一个处理器,那还能实现并行处理吗?众所周知,并行处理需要多个处理单元才能实现。不过,如果我们放宽对"处理单元"的定义,那么那些正在等待I/O操作完成的线程其实并不算是处于空闲状态。毕竟,将数据写入磁盘也是程序任务的一部分吧?如果有两个线程,其中一个负责将数据写入磁盘,另一个则在CPU上执行指令,那么这应该算作并行处理吧?此外,像磁盘和网络这样的组件也可以与CPU一起同时工作来协助程序的执行。即便在这种情况下,我们通常仍将"并行处理"这一术语用于描述计算过程,而不是I/O操作。不过,许多教材中会用"伪并行处理"来描述这种情况。所谓"伪并行处理",指的是由单个处理器来模拟多个任务同时执行的场景。系统通过定时切换任务或是在某个任务需要执行阻塞型I/O操作时进行切换,从而实现这种效果。

相关推荐
小王师傅661 小时前
【Java结构化梳理】泛型-初步了解-中
java·开发语言
CQU_JIAKE1 小时前
[q]4.25
java·开发语言·前端
涵涵(互关)1 小时前
语法大全-only-writer
开发语言·前端·vue.js·typescript
YaBingSec1 小时前
玄机网络安全靶场:GeoServer XXE 任意文件读取(CVE-2025-58360)
java·运维·网络·安全·web安全·tomcat·ssh
shehuiyuelaiyuehao1 小时前
算法12,滑动窗口,将x减到0的最小操作数
java·数据结构·算法
lulu12165440781 小时前
国内怎么用GPT5.5?基于weelinking零门槛合规接入GPT5.5全系列生产级能力
java·人工智能·python·gpt·ai编程
skywalk81631 小时前
lisp to 块编程 完全的中文编程思路:无空格编程
开发语言·lisp
liulian09161 小时前
【Flutter for OpenHarmony第三方库】Flutter for OpenHarmony 离线模式实现:让你的应用无网也能萌萌哒~
开发语言·flutter·华为·php·学习方法·harmonyos
南宫萧幕1 小时前
基于 DQN 与 Python-Simulink 联合仿真的 HEV 能量管理策略实战
开发语言·python·matlab·汽车·控制