golang快速入门:并发编程(一)

进程、线程和协程

进程:是操作系统中的一个执行实体,它拥有独立的内存空间和系统资源。每个进程都是独立运行的,它们之间相互隔离,通过进程间通信(IPC)来进行数据交换。每个进程都有自己的地址空间、堆栈和文件描述符等。进程之间的切换开销较大,因为需要保存和恢复整个进程的状态。

线程:是进程中的一个执行单元,多个线程可以共享同一个进程的资源。线程拥有自己的堆栈和程序计数器,但共享进程的地址空间和文件描述符等。线程之间的切换开销较小,因为不需要切换整个进程的状态。线程可以并发执行,提高程序的并发性。

协程:是一种用户态的轻量级线程。协程由用户程序控制,而不是由操作系统内核控制。协程可以在同一个线程内切换执行,不需要进行线程切换,因此切换开销非常小。协程之间可以通过协程调度器进行协作和通信。协程通常用于处理大量的并发任务,可以提高程序的性能和响应性。

总结来说,进程是操作系统分配资源的基本单位,线程是进程中的执行单元,而协程是由用户程序控制的轻量级线程。它们都可以用于实现并发编程,但在切换开销、资源消耗和编程模型等方面有所不同。

并行与并发

并发:是指多个任务或操作在时间上重叠执行。在并发计算中,多个任务按照某种调度策略交替执行,每个任务都会在一段时间内执行一部分,然后切换到另一个任务。并发计算通常涉及到单个处理器或单个核心处理器,侧重于在单核CPU中,通过CPU时间分片实现任务的交替执行。

并行:是指同时执行多个任务或操作。在并行计算中,多个任务可以同时进行,每个任务都在不同的处理单元上执行。这可以显著提高计算的速度和效率。并行计算通常涉及到多个处理器、多核处理器或多台计算机,侧重于通过CPU多核能力同时处理多个任务,每个任务分配给单独处理器执行,同一时间点,任务是同时执行的。

Go 语言的并发编程也是基于并发实现的,而非并行。

Go 协程(Goroutine)

Go 语言的协程实现被称之为 Goroutine,由 Go 运行时管理在 Go 语言中通过协程实现并发编程非常简单:我们可以在一个处理进程中通过关键字 ​​go​​​ 启用多个协程,然后在不同的协程中完成不同的子任务。

​Go 协程的底层调度机制

​Go 语言中,协程(goroutine)的底层调度是由 Go 运行时(runtime)里的调度器(Scheduler)来管理的,Go 语言运行时会在底层通过调度器将用户级线程交给操作系统的系统级线程去处理,如果在运行过程中遇到某个 IO 操作而暂停运行,调度器会将用户级线程和系统级线程分离,以便让系统级线程去处理其他用户级线程,而当 IO 操作完成,需要恢复运行,调度器又会调度空闲的系统级线程来处理这个用户级线程,从而达到并发处理多个协程的目的。

G-M-P 模型

调度器使用了 M:N 调度技术来映射用户态的 goroutines 到内核线程。这个过程涉及到几个关键的组件:G、P 和 M。

  1. G - Goroutine:代表一个轻量级的用户态线程,不是系统级别的线程。Go 程序能够同时并发运行数以千计的 goroutines。
  2. P - Processor:可以看作是调度器的上下文环境,处理器 P 维护了一组本地的 runnable goroutines 队列。P 的数量通常由环境变量 GOMAXPROCS 确定,默认值为机器的 CPU 核心数。
  3. M - Machine:代表了内核线程,M 是由 OS 线程支持的。在 M 上运行 P 来执行 goroutine 任务。M 负责将 P 上的任务调度到系统线程上执行。

如图所示:

  • 全局队列:存放待运行 G
  • P 的本地队列:和全局队列类似,存放的也是待运行的 G,存放数量上限 256 个。新建 G 时,G 优先加入到 P 的本地队列,如果队列满了,则会把本地队列中的一半 G 移动到全局队列
  • P 列表:所有的 P 都在程序启动时创建,保存在数组中,最多有 GOMAXPROCS 个,可通过 ​runtime.GOMAXPROCS(N)​ 修改,N 表示设置的个数
  • M:每个 M 代表一个内核线程,操作系统调度器负责把内核线程分配到 CPU 的核心上执行。

G-M-P 的调度流程大致如下:

  • 当一个 goroutine 被创建时,它会首先被放入一个 P 的本地队列中。如果 P 的本地队列满了,它会被放入全局队列中。
  • P 会选择一个 goroutine 来执行,P 绑定到 M 上,并在 M 上处理自己的本地队列中的 goroutines。
  • 如果一个 goroutine 执行时进行了 syscall(系统调用)或出现了网络 I/O 阻塞,当前的 M 可能会被阻塞。此时,P 会与 M 分离并尝试获取另一个空闲的 M,以继续执行本地队列中的其他 goroutine 任务。
  • 如果没有空闲的 M,P 将自身挂起等待 M 变为可用或创建新的 M(有上限控制)。
  • 工作窃取:如果有 P 发现自己没有 goroutine 可运行,它可能会尝试从其他 P 的队列中"窃取"一半的 Goroutines,从而达成负载均衡。
  • Go 调度器还会利用 netpoller 来监控网络 I/O 操作,实现 goroutines 的非阻塞 I/O 调度。

整个调度过程是在用户态完成的,无需内核介入,减少了上下文切换,从而提高并发性能。调度器的实现使得数以万计的 goroutines 能够高效地在有限的 OS 线程之上运行,并且有效利用现代多核处理器的并行能力。

协程简单使用

css 复制代码
package main

import (
    "fmt"
    "time"
)

func add(a, b int) {
    var c = a + b
    fmt.Printf("%d + %d = %d\n", a, b, c)
}

func main() {
    go add(1, i)
    time.Sleep(1 * time.Second)
}

在这段代码中包含了两个协程,一个是显式的,通过 ​​go​​ 关键字声明的这条语句,表示启用一个新的协程来处理加法运算,另一个是隐式的,即 ​​main​​ 函数本身也是运行在一个主协程中,可以看到在代码中我们使用了time.Sleep(1 * time.Second)延时了1s,是因为我们不知道子协程什么时候执行完毕,运行到了什么状态。在主协程中启动子协程后,程序就退出运行了,加延时是为了保证子协程中的所有代码全部执行完毕。

基于共享内存实现协程通信

sync.WaitGroup 控制协程

可以通过 Go 官方标准库 ​​sync​​ 包提供的 ​​sync.WaitGroup​​ 更加优雅地实现协程退出。

​​​sync.WaitGroup​​ 类型是开箱即用的,也是并发安全的。该类型提供了以下三个方法:

  • ​Add​​WaitGroup​ 类型有一个计数器,默认值是 0,我们可以通过 ​Add​ 方法来增加这个计数器的值,通常我们可以通过个方法来标记需要等待的子协程数量;
  • ​Done​:当某个子协程执行完毕后,可以通过 ​Done​ 方法标记已完成,该方法会将所属 ​WaitGroup​ 类型实例计数器值减 1,通常可以通过 ​defer​ 语句来调用它;
  • ​Wait​​Wait​ 方法的作用是阻塞当前协程,直到对应 ​WaitGroup​ 类型实例的计数器值归零,如果在该方法被调用的时候,对应计数器的值已经是 0,那么它将不会做任何事情。
go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

func add(a, b int, doneFunc func()) {
    defer func() {
        doneFunc() // 子协程执行完毕后将计数器-1
    }()
    c := a + b
    fmt.Printf("%d + %d = %d\n", a, b, c)
}

func main() {
    start := time.Now()
    wg := sync.WaitGroup{}
    wg.Add(2) // 初始化计数器数目为2
    for i := 0; i < 2; i++ {
        go add(1, i, wg.Done)
    }

    wg.Wait() // 等待子协程全部执行完毕退出
    end := time.Now()
    consume := end.Sub(start).Seconds()
    fmt.Println("程序执行耗时(s):", consume)
}

首先在主协程中声明了一个 ​​sync.WaitGroup​​ 类型的 ​​wg​​ 变量,然后调用 ​​Add​​ 方法设置等待子协程数为 2,然后循环启动子协程,并将 ​​wg.Done​​ 作为 ​​defer​​ 函数传递过去,最后,我们通过 ​​wg.Wait()​​ 等到 ​​sync.WaitGroup​​ 计数器值为 0 时退出程序。

​​​​​sync.WaitGroup​​​​ 其实是 Go 底层封装的一个更加优雅的计数器实现,和自行实现的计数器一样,本质上都是通过计数信号量基于共享内存实现协程之间的同步和通信,在基于共享内存实现协程通信时,一个重要的、不可忽视的要素就是如何确保不同协程同时访问同一资源的数据竞争和并发安全问题。

在 Go 语言的编程哲学中,创始人 Rob Pike 推介「Don't communicate by sharing memory, share memory by communicating(不要通过共享内存来通信,而应该通过通信来共享内存)」

基于锁和原子操作实现并发安全

互斥锁(Mutex)

Mutex 是 Go 语言中非常重要的并发原语,用于互斥访问共享资源的对象。Mutex 可以保证在同一时间只有一个 goroutine 可以访问共享资源,从而避免数据竞争,保证数据的安全性。

erlang 复制代码
package main

import (
	"fmt"
	"sync"
)

var (
	mutex   sync.Mutex
	balance int
)

func deposit(value int, wg *sync.WaitGroup) {
	mutex.Lock()
	fmt.Printf("Depositing %d to account with balance: %d\n", value, balance)
	balance += value
	mutex.Unlock()
	wg.Done()
}

func main() {
	balance = 1000
	var wg sync.WaitGroup
	wg.Add(2)
	go deposit(200, &wg)
	go deposit(100, &wg)
	wg.Wait()
	fmt.Printf("New Balance %d\n", balance)
}

在这个示例中,我们使用一个 Mutex 来同步访问一个银行账户的余额。每次存款时,我们都会锁定 Mutex,然后更新余额,最后解锁 Mutex。

Mutex 的使用需要注意以下几点:

  1. Mutex 必须在使用前进行初始化。
  2. Mutex 必须在使用完毕后进行释放。
  3. Mutex 不能在多个 goroutine 之间同时使用。
  4. Mutex 不能在递归调用中使用。
  5. 不要忘记解锁互斥锁
  6. 不要在多个函数之间直接传递互斥锁
读写锁(RWMutex)

​sync.RWMutex​​ 分读锁和写锁,会对读操作和写操作区分对待,在读锁占用的情况下,会阻止写,但不阻止读,也就是多个 goroutine 可同时获取读锁,读锁调用 ​​RLock()​​ 方法开启,通过 ​​RUnlock​​ 方法释放;而写锁会阻止任何其他 goroutine(无论读和写)进来,整个锁相当于由该 goroutine 独占,和 Mutex 一样,写锁通过 ​​Lock​​ 方法启用,通过 ​​Unlock​​ 方法释放。

erlang 复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

var (
	rwMutex sync.RWMutex
	value   int
)

func readValue(wg *sync.WaitGroup) {
	defer wg.Done()
	rwMutex.RLock()
	fmt.Printf("Read value: %d\n", value)
	time.Sleep(1 * time.Second)
	rwMutex.RUnlock()
}

func writeValue(newValue int, wg *sync.WaitGroup) {
	defer wg.Done()
	rwMutex.Lock()
	fmt.Printf("Write value: %d\n", newValue)
	value = newValue
	rwMutex.Unlock()
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go writeValue(10, &wg)
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go readValue(&wg)
	}
	wg.Wait()
}

在这个示例中,我们使用一个 RWMutex 来同步读写一个共享变量。我们创建了一个写者 goroutine 和多个读者 goroutines。写者会锁定 RWMutex 进行写操作,读者会锁定 RWMutex 进行读操作。由于 RWMutex 允许多个读者同时锁定,所以这些读者 goroutines 可以并行执行。

条件变量(sync.Cond)

​sync​​包还提供了一个条件变量类型 ​​sync.Cond​​,它可以让一组 goroutine 等待或者宣布某事件的发生,条件变量总是与互斥锁​​sync.Mutex​​​​ 或 ​​​sync.RWMutex​​​)一起使用,用来协调想要访问共享资源的线程。

不过,与互斥锁不同,条件变量 ​​sync.Cond​​​ 的主要作用并不是保证在同一时刻仅有一个线程访问某一个共享资源,而是在对应的共享资源状态发生变化时,通知其它因此而阻塞的线程。条件变量总是和互斥锁组合使用,互斥锁为共享资源的访问提供互斥支持,而条件变量可以就共享资源的状态变化向相关线程发出通知,重在「协调」。

​​​​sync.Cond​​​​ 是一个结构体:

go 复制代码
type Cond struct {
  noCopy noCopy

  // L is held while observing or changing the condition
  L Locker

  notify  notifyList
  checker copyChecker
}

提供了三个方法:

scss 复制代码
// 等待通知
func (c *Cond) Wait() {
  c.checker.check()
  t := runtime_notifyListAdd(&c.notify)
  c.L.Unlock()
  runtime_notifyListWait(&c.notify, t)
  c.L.Lock()  
}

// 单发通知
func (c *Cond) Signal() {
  c.checker.check()
  runtime_notifyListNotifyOne(&c.notify)  
}

// 广播通知
func (c *Cond) Broadcast() {
  c.checker.check()
  runtime_notifyListNotifyAll(&c.notify)  
}

通过 ​​sync.NewCond​​ 返回对应的条件变量实例,初始化的时候需要传入互斥锁,该互斥锁实例会赋值给 ​​sync.Cond​​ 的 ​​L​​ 属性:

css 复制代码
locker := &sync.Mutex{}
cond := sync.NewCond(locker)

sync.Cond​​ 主要实现一个条件变量,假设 goroutine A 执行前需要等待另外一个 goroutine B 的通知,那么处于等待状态的 goroutine A 会保存在一个通知列表,也就是说需要某种变量状态的 goroutine A 将会等待(Wait)在那里,当某个时刻变量状态改变时,负责通知的 goroutine B 会通过对条件变量通知的方式(Broadcast/Signal)来通知处于等待条件变量的 goroutine A,这样就可以在共享内存中实现类似「消息通知」的同步机制。

Signal

假设我们有一个读取器和一个写入器,读取器必须依赖写入器对缓冲区进行数据写入后,才可以从缓冲区中读取数据,写入器每次完成写入数据后,都需要通过某种通知机制通知处于阻塞状态的读取器,告诉它可以对数据进行访问,这种场景正好可以通过条件变量来实现:

go 复制代码
package main

import (
  "bytes"
  "fmt"
  "io"
  "sync"
  "time"
)

// 数据 bucket
type DataBucket struct {
  buffer *bytes.Buffer  //缓冲区
  mutex *sync.RWMutex //互斥锁
  cond  *sync.Cond //条件变量
}

func NewDataBucket() *DataBucket {
  buf := make([]byte, 0)
  db := &DataBucket{
    buffer:     bytes.NewBuffer(buf),
    mutex: new(sync.RWMutex),
  }
  db.cond = sync.NewCond(db.mutex.RLocker())
  return db
}

// 读取器
func (db *DataBucket) Read(i int) {
  db.mutex.RLock()   // 打开读锁
  defer db.mutex.RUnlock()  // 结束后释放读锁
  var data []byte
  var d byte
  var err error
  for {
    //每次读取一个字节
    if d, err = db.buffer.ReadByte(); err != nil {
      if err == io.EOF { // 缓冲区数据为空时执行
        if string(data) != "" {  // data 不为空,则打印它
          fmt.Printf("reader-%d: %s\n", i, data)
        }
        db.cond.Wait() // 缓冲区为空,通过 Wait 方法等待通知,进入阻塞状态
        data = data[:0]  // 将 data 清空
        continue
      }
    }
    data = append(data, d) // 将读取到的数据添加到 data 中
  }
}

// 写入器
func (db *DataBucket) Put(d []byte) (int, error) {
  db.mutex.Lock()   // 打开写锁
  defer db.mutex.Unlock()  // 结束后释放写锁
  //写入一个数据块
  n, err := db.buffer.Write(d)
  db.cond.Signal()  // 写入数据后通过 Signal 通知处于阻塞状态的读取器
  return n, err
}

func main() {
  db := NewDataBucket()
  go db.Read(1) // 开启读取器协程
  go func(i int) {
    d := fmt.Sprintf("data-%d", i)
    db.Put([]byte(d))  // 写入数据到缓冲区
  }(1)  // 开启写入器协程
  time.Sleep(100 * time.Millisecond)
}

上面例子使用了读写互斥锁,在读取器里面使用读锁,在写入器里面使用写锁,并且通过 ​​defer​​ 语句释放锁,然后在锁保护的情况下,通过条件变量协调读写线程:在读线程中,当缓冲区为空的时候,通过 ​​db.cond.Wait()​​ 阻塞读线程;在写线程中,当缓冲区写入数据的时候通过 ​​db.cond.Signal()​​ 通知读线程继续读取数据。

再看一个例子:

go 复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var m sync.Mutex
	c := sync.NewCond(&m)
	queue := make([]interface{}, 0, 10)

	removeFromQueue := func(delay time.Duration) {
		time.Sleep(delay)
		c.L.Lock()
		queue = queue[1:]
		fmt.Println("Removed from queue")
		c.L.Unlock()
		c.Signal()
	}

	for i := 0; i < 10; i++ {
		c.L.Lock()
		for len(queue) == 2 {
			c.Wait()
		}
		fmt.Println("Adding to queue")
		queue = append(queue, struct{}{})
		go removeFromQueue(1 * time.Second)
		c.L.Unlock()
	}
}

在这个示例中,我们创建了一个队列和一个条件变量。我们在循环中向队列添加元素,如果队列的长度达到 2,我们就调用 ​​c.Wait()​​ 等待条件变量的信号。在另一个 goroutine 中,我们在一段时间后从队列中移除元素,并调用 ​​c.Signal()​​ 发送信号,这会唤醒等待的 goroutine。

注意,调用 ​​c.Wait()​​ 时,我们需要持有条件变量的锁。​​c.Wait()​​ 会自动释放锁,并将 goroutine 放入等待队列。当 ​​c.Signal()​​ 被调用时,​​c.Wait()​​​ 会自动重新获取锁。这是为了保证在条件变量的信号被发送和接收之间,共享数据的状态不会改变。

Broadcast

上述第一个示例代码只有一个读取器,一个写入器,如果都有多个呢?我们可以通过启动多个读写协程来模拟,此外,通知单个阻塞线程用 ​​Signal​​ 方法,通知多个阻塞线程需要使用 ​​Broadcast​​ 方法,按照这个思路,我们来改写上述示例代码如下:

go 复制代码
package main

import (
  "bytes"
  "fmt"
  "io"
  "sync"
  "time"
)

// 数据 bucket
type DataBucket struct {
  buffer *bytes.Buffer  //缓冲区
  mutex *sync.RWMutex //互斥锁
  cond  *sync.Cond //条件变量
}

func NewDataBucket() *DataBucket {
  buf := make([]byte, 0)
  db := &DataBucket{
    buffer:     bytes.NewBuffer(buf),
    mutex: new(sync.RWMutex),
  }
  db.cond = sync.NewCond(db.mutex.RLocker())
  return db
}

// 读取器
func (db *DataBucket) Read(i int) {
  db.mutex.RLock()   // 打开读锁
  defer db.mutex.RUnlock()  // 结束后释放读锁
  var data []byte
  var d byte
  var err error
  for {
    //每次读取一个字节
    if d, err = db.buffer.ReadByte(); err != nil {
      if err == io.EOF { // 缓冲区数据为空时执行
        if string(data) != "" {  // data 不为空,则打印它
          fmt.Printf("reader-%d: %s\n", i, data)
        }
        db.cond.Wait() // 缓冲区为空,通过 Wait 方法等待通知,进入阻塞状态
        data = data[:0]  // 将 data 清空
        continue
      }
    }
    data = append(data, d) // 将读取到的数据添加到 data 中
  }
}

// 写入器
func (db *DataBucket) Put(d []byte) (int, error) {
  db.mutex.Lock()   // 打开写锁
  defer db.mutex.Unlock()  // 结束后释放写锁
  //写入一个数据块
  n, err := db.buffer.Write(d)
  db.cond.Broadcast()  // 写入数据后通过 Broadcast 通知处于阻塞状态的读取器
  return n, err
}

func main() {
  db := NewDataBucket()
  for i := 1; i < 3; i++ {  // 启动多个读取器
    go db.Read(i)
  }
  for j := 0; j < 10; j++  {  // 启动多个写入器
    go func(i int) {
      d := fmt.Sprintf("data-%d", i)
      db.Put([]byte(d))  // 写入数据到缓冲区
    }(j)
    time.Sleep(100 * time.Millisecond) // 每次启动一个写入器暂停100ms,让读取器阻塞
  }
}

通过互斥锁+条件变量,我们可以非常方便的实现多个 Go 协程之间的通信。

​sync.Cond​​ 的 ​​Broadcast()​​ 方法可以唤醒所有等待的 goroutine。当你有一个事件会影响所有等待的 goroutine 时,你应该使用 ​​Broadcast()​​​​。​

erlang 复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, cond *sync.Cond) {
	cond.L.Lock()
	cond.Wait()
	fmt.Printf("Worker %d fired\n", id)
	cond.L.Unlock()
}

func main() {
	var wg sync.WaitGroup
	var m sync.Mutex
	cond := sync.NewCond(&m)

	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			worker(id, cond)
		}(i)
	}

	fmt.Println("Ready...Set...Go!")
	time.Sleep(2 * time.Second)
	cond.Broadcast()
	wg.Wait()
}

在这个示例中,我们创建了 10 个 worker goroutines,它们都在等待一个 "开始" 的信号。当我们调用 ​​cond.Broadcast()​​​ 时,所有的 worker goroutines 都会收到这个信号,并开始执行。

原子操作 Atomic

Go 语言的 ​​sync/atomic​​​ 包提供了底层的原子级内存操作,包括对整数类型和指针的原子加载(Load)、存储(Store)、增加(Add)、比较并交换(Compare and Swap,简称 CAS)等。

原子增加
go 复制代码
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

var (
	sum int64
)

func worker(wg *sync.WaitGroup) {
	defer wg.Done()
	atomic.AddInt64(&sum, 1)
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1000)
	for i := 0; i < 1000; i++ {
		go worker(&wg)
	}
	wg.Wait()
	fmt.Println(sum) // Output: 1000
}

在这个示例中,我们创建了 1000 个 goroutine,每个 goroutine 都会原子地增加 ​​sum​​​ 的值。

原子比较并交换
go 复制代码
package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var value int64 = 20
	fmt.Println(atomic.CompareAndSwapInt64(&value, 20, 21)) // Output: true
	fmt.Println(value) // Output: 21
}

在这个示例中,我们原子地比较 ​​value​​​ 是否等于 20,如果等于,就将其设置为 21。

​原子操作和互斥锁都是用于在并发环境中保护共享数据的工具,但它们的使用场景和特性是不同的。

原子操作通常用于简单的、单一的读写操作,例如增加一个计数器或者更新一个标志。原子操作的优点是它们非常快速且不会导致 goroutine 阻塞。然而,原子操作不能用于保护多个操作构成的临界区,也不能用于同步多个 goroutine 的执行顺序。

互斥锁则可以用于保护复杂的临界区,例如一个操作序列或者一个数据结构的多个字段。互斥锁也可以用于同步多个 goroutine 的执行顺序。然而,互斥锁的操作比原子操作要慢,且可能导致 goroutine 阻塞。

所以,原子操作并不只是为了简化互斥锁的使用,而是为了提供一种更轻量级的同步机制。在选择使用原子操作还是互斥锁时,你需要根据你的具体需求和场景来决定。

相关推荐
Pandaconda几秒前
【Golang 面试题】每日 3 题(三十九)
开发语言·经验分享·笔记·后端·面试·golang·go
编程小筑35 分钟前
R语言的编程范式
开发语言·后端·golang
技术的探险家38 分钟前
Elixir语言的文件操作
开发语言·后端·golang
ss2731 小时前
【2025小年源码免费送】
前端·后端
Ai 编码助手1 小时前
Golang 中强大的重试机制,解决瞬态错误
开发语言·后端·golang
齐雅彤2 小时前
Lisp语言的区块链
开发语言·后端·golang
齐雅彤2 小时前
Lisp语言的循环实现
开发语言·后端·golang
梁雨珈2 小时前
Lisp语言的物联网
开发语言·后端·golang
邓熙榆3 小时前
Logo语言的网络编程
开发语言·后端·golang
羊小猪~~7 小时前
MYSQL学习笔记(四):多表关系、多表查询(交叉连接、内连接、外连接、自连接)、七种JSONS、集合
数据库·笔记·后端·sql·学习·mysql·考研