2024年4月

mydocker-exec.png

本文为从零开始写 Docker 系列第十一篇,实现类似 docker exec 的功能,使得我们能够进入到指定容器内部。


完整代码见:
https://github.com/lixd/mydocker
欢迎 Star

推荐阅读以下文章对 docker 基本实现有一个大致认识:


开发环境如下:

root@mydocker:~# lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.2 LTS
Release:	20.04
Codename:	focal
root@mydocker:~# uname -r
5.4.0-74-generic


注意:需要使用 root 用户

1. 概述

上一篇已经实现了
mydocker logs
命令,可以查看容器日志了。本篇主要实现
mydocker exec
,让我们可以直接进入到容器内部,查看容器内部的文件、调试应用程序、执行命令等等。

下面这篇文章分析了 Docker 是如何使用 Linux Namespace 来实现视图隔离的,那么 mydocker exec 也是需要在 Namespace 上做文章。

[探索 Linux Namespace:Docker 隔离的神奇背后]

2. 核心原理

docker exec
实则是将当前进程添加到指定容器对应的 namespace 中

,从而可以看到容器中的进程信息、网络信息等。

因此我们的
mydocker exec
具体实现包括两部分:

  • 根据容器 ID 找到对应 PID,然后找到 Namespace
  • 将当前进程切换到对应 Namespace

setns

将进程加入到对应的 Namespace 很简单,Linux提供了
setns
系统调用给我们使用。

setns 是一个系统调用,可以根据提供的 PID 再次进入到指定的 Namespace 中
。它需要先打开
/proc/[pid/ns/
文件夹下对应的文件,然后使当前进程进入到指定的 Namespace 中。

但是用 Go 来实现则存在一个致命问题:
setns 调用需要单线程上下文,而 GoRuntime 是多线程的

准确的说是 MountNamespace。

Linux 的 Namespace 是一种资源隔离机制,它允许将一组进程的视图隔离到系统的不同部分,比如 PID Namespace、Network Namespace 等。

setns
系统调用允许进程加入(或重新进入)到指定的 Namespace 中。由于 Namespace 涉及到整个进程的资源隔离,因此需要在进程的上下文中执行,以确保进程及其所有线程都在相同的 Namespace 中

Go Runtime 是多线程的,这意味着 Go 程序通常会有多个线程在同时运行。这种多线程模型与
setns
调用所需的单线程上下文不兼容。

Goroutine 会随机在底层 OS 线程之间切换,而不是固定在某个线程,因此在 Go 中执行 setns 不能准确的知道是操作到哪个线程了,结果是不确定的,因此需要特殊处理。

这个问题对 Go 本身来说没有太好的解决办法,
#14163
是 Github 上对一些解决方案的讨论,不过最终还是被拒绝了。

不过好消息是 C 语言可以通过 gcc 的 扩展
attribute
((constructor))

来实现
程序启动前执行特定代码
,因此 Go 就可以通过 cgo 嵌入 这样的一段 C 代码来完成 runtime 启动前执行特定的 C 代码。

runC 中的
nsenter
也是借助 cgo 实现的。

具体代码如下:

//go:build linux && !gccgo
// +build linux,!gccgo

package nsenter

/*
#cgo CFLAGS: -Wall
extern void nsexec();
void __attribute__((constructor)) init(void) {
	// something
}
*/
import "C"

这段代码就会在 Go Runtime 启动前执行这里定义的 init() 函数,我们只需要把 setns 的调用放在这个 init 方法中即可。

cgo

cgo 是一个很炫酷的功能,允许 Go 程序去调用 C 的函数与标准库。你只需要以一种特殊的方式在 Go 的源代码里写出需要调用的 C 的代码,cgo 就可以把你的 C 源码文件和 Go 文件整合成一个包。

下面举一个最简单的例子,在这个例子中有两个函数一Random 和 Seed,在
它们里面调用了 C 的 random 和 srandom 函数。

package main

/*
#include <stdlib.h>
*/
import "C"
import (
    "fmt"
)

func main() {
    Seed(123)
    // Output:Random:  128959393
    fmt.Println("Random: ", Random())
}

// Seed 初始化随机数产生器
func Seed(i int) {
    C.srandom(C.uint(i))
}

// Random 产生一个随机数
func Random() int {
    return int(C.random())
}

这段代码导入了一个叫 C 的包,但是你会发现在 Go 标准库里面并没有这个包,那是因为这根本就不是一个真正的包,而只是 Cgo 创建的一个特殊命名空间,用来与 C 的命名空间交流。

这两个函数都分别调用了 C 里面的 random 和 uint 函数,然后对它们进行了类型转换。这就实现了 Go 代码里面调用 C 的功能。

3. 实现

首先,自然是需要在 C 中实现 setns 核心逻辑,根据 PID 实现 Namespace 切换。

其次,由于使用 C 的
constructor
方式,以 init 形式执行的 setns 这段代码,意味这,执行任何 mydocker 命令的时候这段代码都会执行,因此需要限制,只有 mydocker exec 时才切换 Namespace。

大致流程如下图所示:

mydocker-exec-process.png

setns

setns 的 C 实现具体如下:

package nsenter

/*
#define _GNU_SOURCE
#include <unistd.h>
#include <errno.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>

__attribute__((constructor)) void enter_namespace(void) {
   // 这里的代码会在Go运行时启动前执行,它会在单线程的C上下文中运行
	char *mydocker_pid;
	mydocker_pid = getenv("mydocker_pid");
	if (mydocker_pid) {
		fprintf(stdout, "got mydocker_pid=%s\n", mydocker_pid);
	} else {
		fprintf(stdout, "missing mydocker_pid env skip nsenter");
		// 如果没有指定PID就不需要继续执行,直接退出
		return;
	}
	char *mydocker_cmd;
	mydocker_cmd = getenv("mydocker_cmd");
	if (mydocker_cmd) {
		fprintf(stdout, "got mydocker_cmd=%s\n", mydocker_cmd);
	} else {
		fprintf(stdout, "missing mydocker_cmd env skip nsenter");
		// 如果没有指定命令也是直接退出
		return;
	}
	int i;
	char nspath[1024];
	// 需要进入的5种namespace
	char *namespaces[] = { "ipc", "uts", "net", "pid", "mnt" };

	for (i=0; i<5; i++) {
		// 拼接对应路径,类似于/proc/pid/ns/ipc这样
		sprintf(nspath, "/proc/%s/ns/%s", mydocker_pid, namespaces[i]);
		int fd = open(nspath, O_RDONLY);
		// 执行setns系统调用,进入对应namespace
		if (setns(fd, 0) == -1) {
			fprintf(stderr, "setns on %s namespace failed: %s\n", namespaces[i], strerror(errno));
		} else {
			fprintf(stdout, "setns on %s namespace succeeded\n", namespaces[i]);
		}
		close(fd);
	}
	// 在进入的Namespace中执行指定命令,然后退出
	int res = system(mydocker_cmd);
	exit(0);
	return;
}
*/
import "C"

为什么要这么写,前面 setns 部分已经解释了,这里简单提一下,这里主要使用了构造函数,然后导入了 C 模块,一旦这个包被引用,它就会在所有 Go Runtime 启动之前执行,这样就避免了 Go 多线程导致的无法执行 setns 的问题。

即:这段程序执行完毕后,Go 程序才会执行。

同时,为了避免执行其他命令的时候这段 setns 的逻辑影响到其他功能,因此,在这段 C 代码前面一开始的位置就添加了环境变量检测,没有对应的环境变量时,就直接退出。

    mydocker_cmd = getenv("mydocker_cmd");
		if (mydocker_cmd) {
       // fprintf(stdout, "got mydocker_cmd=%s\n", mydocker_cmd);
    } else {
       // fprintf(stdout, "missing mydocker_cmd env skip nsenter");
       // 如果没有指定命令也是直接退出
       return;
    }

对于不使用 exec 功能的 Go 代码,只要不设置对应的环境变量,这段 C 代码就不会运行,这样就不会影响原来的逻辑。

注意:只有在你的 Go 应用程序中注册、导入了这个包,才会调用这个构造函数

就像这样:

import (
    _ "mydocker/nsenter"
)

使用 cgo 我们无法直接获取传递给程序的参数,可用的做法是,通过 go exec 创建一个自身运行进程,然后通过传递环境变量的方式,传递给 cgo 参数值。

体现在 runc 中就是
runc create → runc init
,runc 中有很多细节,他通过环境变量传递 netlink fd,然后进行通信。

execCommand

在 main_command.go 中增加一个 execCommand,具体如下:

var execCommand = cli.Command{
    Name:  "exec",
    Usage: "exec a command into container",
    Action: func(context *cli.Context) error {
       // 如果环境变量存在,说明C代码已经运行过了,即setns系统调用已经执行了,这里就直接返回,避免重复执行
       if os.Getenv(EnvExecPid) != "" {
          log.Infof("pid callback pid %v", os.Getgid())
          return nil
       }
       // 格式:mydocker exec 容器名字 命令,因此至少会有两个参数
       if len(context.Args()) < 2 {
          return fmt.Errorf("missing container name or command")
       }
       containerName := context.Args().Get(0)
       // 将除了容器名之外的参数作为命令部分
       var commandArray []string
       for _, arg := range context.Args().Tail() {
          commandArray = append(commandArray, arg)
       }
       ExecContainer(containerName, commandArray)
       return nil
    },
}

然后添加到 main 函数中去:

func main(){
    // 省略其他内容
    app.Commands = []cli.Command{
       initCommand,
       runCommand,
       commitCommand,
       listCommand,
       logCommand,
       execCommand,
    }
}

这里主要是将获取到的容器名和需要的命令处理完成后,交给下面的函数,下面看一下 ExecContainer 的实现。

ExecContainer

exec 命令核心实现就是 ExecContainer 方法。

// nsenter里的C代码里已经出现mydocker_pid和mydocker_cmd这两个Key,主要是为了控制是否执行C代码里面的setns.
const (
	EnvExecPid = "mydocker_pid"
	EnvExecCmd = "mydocker_cmd"
)

func ExecContainer(containerId string, comArray []string) {
	// 根据传进来的容器名获取对应的PID
	pid, err := getPidByContainerId(containerId)
	if err != nil {
		log.Errorf("Exec container getContainerPidByName %s error %v", containerId, err)
		return
	}

	cmd := exec.Command("/proc/self/exe", "exec")
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	// 把命令拼接成字符串,便于传递
	cmdStr := strings.Join(comArray, " ")
	log.Infof("container pid:%s command:%s", pid, cmdStr)
	_ = os.Setenv(EnvExecPid, pid)
	_ = os.Setenv(EnvExecCmd, cmdStr)

	if err = cmd.Run(); err != nil {
		log.Errorf("Exec container %s error %v", containerId, err)
	}
}

首先是通过ContainerId 找到进程 PID,具体实现如下:

因为之前已经记录了容器信息,因此这里直接读取对应文件就可以找到了。

func getPidByContainerId(containerId string) (string, error) {
	// 拼接出记录容器信息的文件路径
	dirPath := fmt.Sprintf(container.InfoLocFormat, containerId)
	configFilePath := path.Join(dirPath, container.ConfigName)
	// 读取内容并解析
	contentBytes, err := os.ReadFile(configFilePath)
	if err != nil {
		return "", err
	}
	var containerInfo container.Info
	if err = json.Unmarshal(contentBytes, &containerInfo); err != nil {
		return "", err
	}
	return containerInfo.Pid, nil
}

然后则是通过 exec 简单 fork 出了一个进程,并把这个进程的标准输入输出都绑定到宿主机的 stdin、stdout、stderr 上。

    cmd := exec.Command("/proc/self/exe", "exec")
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    // 把命令拼接成字符串,便于传递
    cmdStr := strings.Join(comArray, " ")

最关键的是设置环境变量的这两句:

    _ = os.Setenv(EnvExecPid, pid)
    _ = os.Setenv(EnvExecCmd, cmdStr)

设置了这两个环境变量,于是在新的进程里,前面的 nsenter 部分的 C 代码就会执行到 setns 部分逻辑,从而将进程加入到对应的 Namespace 中进行操作了。

C 代码中根据环境变量拿到 PID 和要执行的命令,首先根据 PID 找到对应 Namespace,然后将当前进程加入到该 Namespace 然后执行具体命令。

这也是 mydocker exec 命令要实现的效果。

而执行其他命令时,由于没有指定这两个环境变量,因此那段 C 代码不会执行到 setns 这里。

这时应该就可以明白前面一段 C 代码的意义了 。

mydocker_pid = getenv("mydocker_pid");
if (mydocker_pid) {
    // fprintf(stdout, "got mydocker_pid=%s\n", mydocker_pid);
} else {
    // 如果没有指定PID就不需要继续执行,直接退出
    return;
}

执行 exec 命令就会设置这两个环境变量,那么问题来了,执行 exec 之后环境变量就已经存在了,C 代码也运行了,那么再次执行 exec 命令岂不是会重复执行 setns 系统调用?

为了避免重复执行,在 execCommand 中加了如下判断:如果对应环境变量已经存在了就直接返回,啥也不执行。

因为环境变量存在就代表着 C 代码执行了,即setns系统调用执行了,也就是当前已经在这个 namespace 里了。

var execCommand = cli.Command{
    Name:  "exec",
    Usage: "exec a command into container",
    Action: func(context *cli.Context) error {
       // 如果环境变量存在,说明C代码已经运行过了,即setns系统调用已经执行了,这里就直接返回,避免重复执行
       if os.Getenv(EnvExecPid) != "" {
          log.Infof("pid callback pid %v", os.Getgid())
          return nil
       }
       // 省略其他内容
    },
}

至此, mydocker exec 命令实现就完成了,核心就是 setns 系统调用

4. 测试

首先编译最新的 mydocker,然后启动一个后台容器,这里直接把 name 指定为 test,方便观察。

这里要运行交互式命令,例如 top,保证容器能在后台一直运行。

root@mydocker:~/feat-exec/mydocker# go build  .
root@mydocker:~/feat-exec/mydocker# ./mydocker run -d -name test top
{"level":"info","msg":"createTty false","time":"2024-01-30T09:48:33+08:00"}
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-01-30T09:48:33+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-30T09:48:33+08:00"}
{"level":"error","msg":"mkdir dir /root/merged error. mkdir /root/merged: file exists","time":"2024-01-30T09:48:33+08:00"}
{"level":"error","msg":"mkdir dir /root/upper error. mkdir /root/upper: file exists","time":"2024-01-30T09:48:33+08:00"}
{"level":"error","msg":"mkdir dir /root/work error. mkdir /root/work: file exists","time":"2024-01-30T09:48:33+08:00"}
{"level":"info","msg":"mount overlayfs: [/usr/bin/mount -t overlay overlay -o lowerdir=/root/busybox,upperdir=/root/upper,workdir=/root/work /root/merged]","time":"2024-01-30T09:48:33+08:00"}
{"level":"info","msg":"command all is top","time":"2024-01-30T09:48:33+08:00"}

然后查看容器 ID

root@mydocker:~/feat-exec/mydocker# ./mydocker ps
ID           NAME        PID         STATUS      COMMAND     CREATED
2147624410   test        180358      running     top         2024-01-30 09:48:33

然后执行 exec 命令并指定 Id 为 2147624410 进入该容器

root@mydocker:~/feat-exec/mydocker# ./mydocker exec 2147624410 sh
{"level":"info","msg":"container pid:180358 command:sh","time":"2024-01-30T09:48:42+08:00"}
got mydocker_pid=180358
got mydocker_cmd=sh
setns on ipc namespace succeeded
setns on uts namespace succeeded
setns on net namespace succeeded
setns on pid namespace succeeded
setns on mnt namespace succeeded
/ # ps -e
PID   USER     TIME  COMMAND
    1 root      0:00 top
    6 root      0:00 sh
    7 root      0:00 ps -e

在容器内部执行 ps -ef 可以发现 PID 为 1 的进程为 top,这也就意味着已经成功进入到了容器内部。

说明我们的 mydocker exec 命令实现是成功了。

5. 小结

本篇主要实现
mydocker exec
命令,和 docker 实现基本类似,通过 setns 系统调用将当前进程加入到容器所在 Namespace 即可。

比较关键的一点在于,Go Runtime 是多线程的,和 setns 冲突,因此需要使用 Cgo 以
constructor
方式在 Go Runtime 启动之前执行 setns 调用。

最后就是根据是否存在指定环境变量来防止重复执行。


【从零开始写 Docker 系列】
持续更新中,搜索公众号【
探索云原生
】订阅,阅读更多文章。




完整代码见:
https://github.com/lixd/mydocker
欢迎关注~

相关代码见
feat-exec
分支,测试脚本如下:

需要提前在 /root 目录准备好 busybox.tar 文件,具体见第四篇第二节。

# 克隆代码
git clone -b feat-exec https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试 
./mydocker run -d -name c1 top
# 查看容器 Id
./mydocker ps
# 根据 Id 执行 exec 进入对应容器
./mydocker exec ${containerId}

MySQL—MySQL架构

MySQL逻辑架构图如下:

MySQL逻辑架构图

  • Connectors连接器
    :负责跟客户端建立连接;
  • Management Serveices & Utilities系统管理和控制工具
  • Connection Pool连接池
    :管理用户连接,监听并接收连接的请求,转发所有连接的请求到线程管理模块;
  • SQL Interface SQL接口
    :接受用户的SQL命令,并且返回SQL执行结果;
  • Parser解析器
    :SQL传递到解析器的时候会被解析器验证和解析;
  • Optimizer查询优化器
    :SQL语句在查询之前会使用查询优化器对查询进行优化,explain语句查看的SQL语句执行计划,就是由此优化器生成;
  • Cache和Buffer查询缓存
    :在MySQL5.7中包含缓存组件,在MySQL8中移除了;
  • Pluggable Storage Engines存储引擎
    :存储引擎就是存取数据、建立与更新索引、查询数据等技术的实现方法。

MySQL日志文件

MySQL是通过文件系统对数据索引后进行存储的,MySQL从物理结构上可以分为日志文件和数据及索引文件。MySQL在Linux中的数据索引文件和日志文件通常放在/var/lib/mysql目录下。MySQL通过日志记录了数据库操作信息和错误信息。

常用日志文件如下:

  1. 错误日志:/var/log/mysql-error.log
  2. 二进制日志:/var/lib/mysql/mysql-bin
  3. 查询日志: general_query.log
  4. 慢查询日志: slow_query_log.log
  5. 事务重做日志: redo log
  6. 中继日志: relay log
  7. undo log
  8. ....

可以通过以下命令,来查看日志使用信息:

show variables like 'log_%';

日志使用信息

错误日志:error log

  • 默认开启,记录运行过程中
    所有严重的错误信息
    ,及每次启动和关闭的详细信息;
  • 通过log_error和log_warnings配置
    • log_error:指定存储位置;
    • log_warnings:配置警告信息级别
      • log_warnings= 0:不记录告警日志
      • log_warnings= 1:告警信息写入错误日志
      • log_warnings 大于 1:表示各类告警信息,例如:有关网络故障的信息和重新连接信息写入错误日志。

配置

vim /etc/my.cnf

添加如下内容:

错误日志配置

# 错误日志
log_error=/var/log/mysql-error.log
log_warnings=2

重启MySQL

systemctl restart mysqld

配置成功

二进制日志bin log

二进制日志bin log默认是关闭的,需要通过配置来开启,可以记录数据库所有的DDL语句和DML语句,不包括DQL语句。

binlog主要用于实现mysql主从复制、数据备份、数据恢复。

配置中mysql-bin是binlog日志文件的basename,binlog日志文件的完整名称: mysql-bin.000001。

server_id=1
log-bin=mysql-bin

通用查询日志general query log

默认关闭,由于通用查询日志会记录用户的所有操作,其中还包含增删查改等信息,在并发操作大的环境下会产生大量的信息从而导致不必要的磁盘IO,会影响MySQL的性能。如果不是为了调试数据库,不建议开启查询日志。

查询日志查看

#启动开关
general_log={ON|OFF}
#日志文件变量,而general_log_file如果没有指定,默认名是host_name.log
general_log_file=/var/lib/mysql/机器host_name.log

慢查询日志slow query log

默认关闭,通过以下设置开启。记录执行时间超过long_query_time秒的所有查询,便于收集查询时间比较长的SQL语句。

开启配置:

#开启慢查询日志
slow_query_log=ON
# 慢查询的阈值,单位秒
long_query_time=10
#日志记录文件
#如果没有给出fi1e_name值,默认为主机名,后缀为-s1ow.log。
#如果给出了文件名,但不是绝对路径名,文件则写入数据目录。
s1ow_query_log_file=slow_query_log.1og

查看阈值:

show global status 1ike '%s1ow_queries%';
show variables like '%slow_query%' ;
show variab1es like 'long_query_time%';

MySQL数据文件

查看MySQL数据文件

show variables like  '%datadir%';

查看MySQL数据文件

我之前建立了一个
sjdwz_test
库,库中有表
tab_test
使用的是是InnoDB存储引擎,有表
myisam_tab
使用的是MyISAM存储引擎。

sjdwz_test库

进入到刚才输出的数据文件目录:可以看到有一个
sjdwz_test
(库名)为名字的文件夹:

截图

进入后,查看文件:

查看文件

说明

ibdata文件
:使用
系统表空间
存储表数据和索引信息,所有表共同使用一个或者多个ibdata文件。

ibdata文件

  • InnonDB存储引擎的数据文件
    • 表名.frm文件:主要存放与表相关的元数据信息,包括:
      表结构的定义信息
    • 表名.ibd文件:一张表一个ibd文件,存储表数据和索引信息;
  • MyISAM存储引擎的数据文件
    • 表名.frm文件:主要存放与表相关的元数据信息,包括:
      表结构的定义信息
    • 表名.myd文件:主要存放数据;
    • 表名.myi文件:主要存放索引。

写在开头

面试官:同学,AQS的原理知道吗?
我:学过一点,抽象队列同步器,Java中很多同步工具都是基于它的...
面试官:好的,那其中CyclicBarrier学过吗?讲一讲它的妙用吧
我:啊,这个,这个我平时写代码没用过...
面试官:那你回去再学学吧!


随着Java的国内竞争环境逐渐激烈,面试时遇到很多奇葩的问题也是越来越多,以上是模拟的一个面试场景,同学们看下你们能答得上来不?

你训练大语言模型(LLM)用的什么框架?有没有想过不用框架训练 GPT-2?

GitHub 上就有这么一位大神(Andrej Karpathy),他仅用大约 1k 行的 C 代码就完成了 GPT-2 模型的训练,代码纯手撸、不依赖任何机器学习框架,作者这么做仅仅是觉得很有趣。尽管这个项目(llm.c)的教学意义大于实用价值,但开源一周便收获了 15k Star,可见大家对他技术的认可和惊叹。

目光回到本周其他的开源热搜项目,在线的数据库设计工具 DrawDB 和程序员专属的在线工具集合 it-tools,它们方便快捷、点开就能用。Spring 框架也想要赶上 AI 潮流,推出了帮助开发 AI 应用的 Spring 框架 spring-ai,旨在简化开发 AI 应用的复杂度。开源的托管网站平台 Coolify,让你通过点点就能部署在线服务。看来不管是硬核的手撸框架,还是提供便利的工具,在 GitHub 上都是很受欢迎的。

  • 本文目录
    • 1. 开源新闻
      • 1.1 Andrej Karpathy 的开源项目
    • 2. 开源热搜项目
      • 2.1 在线的数据库设计工具:DrawDB
      • 2.2 帮助开发 AI 应用的 Spring 框架:spring-ai
      • 2.3 轻松托管网站和服务的开源平台:Coolify
      • 2.4 程序员的在线工具集合:it-tools
      • 2.5 构建你的第二大脑:Quivr
    • 3. HelloGitHub 热评
      • 3.1 面向所有开发者的学习路线图:developer-roadmap
      • 3.2 假装很忙的摸鱼神器:genact
    • 4. 结尾

1. 开源新闻

1.1 Andrej Karpathy 的开源项目

上面说的
llm.c
项目作者 Andrej Karpathy,他博士就读于斯坦福大学,曾就职于特斯拉的自动驾驶部门负责人、OpenAI 的创始成员。

Andrej Karpathy 热衷于深度神经网络和开源,在 GitHub 上有 71k 的粉丝。他的另外一个开源项目 nanoGPT,也是 GitHub 热榜(Trending)的常客。

nanoGPT 是用于训练/微调中等规模 GPT 模型的库。它是对 minGPT 的重写,这次的重点是速度和效率而不是教育性,值得一提的是 minGPT 的作者也是 Andrej Karpathy。

GitHub 地址:
https://github.com/karpathy/nanoGPT

2. 开源热搜项目

2.1 在线的数据库设计工具:DrawDB

主语言:JavaScript

Star:4.7k

周增长:3.8k

这个开源项目是一个免费、简单、强大的数据库实体关系(DBER)在线编辑器,无需注册即可直接在浏览器中使用。它提供了直观、可视化的操作界面,用户通过点击即可构建数据库表和导出建表语句,还可以导入建表语句,实现可视化编辑、错误检查等。支持 MySQL、PostgreSQL、SQLite、MariaDB、SQL Server 共 5 种常用的关系数据库。

GitHub 地址→
https://github.com/drawdb-io/drawdb

2.2 帮助开发 AI 应用的 Spring 框架:spring-ai

主语言:Java

Star:1.7k

周增长:300

这是由 Spring 官方开源的用于简化包含 AI 功能的应用开发的 Java 框架,它可以轻松接入 OpenAI、Microsoft、Amazon、Google 和 Huggingface 等主流模型供应商,以及聊天、文本生成图像的模型类型,支持提示工程、AI 模型转 POJO 对象、矢量数据库、RAG(检索增强生成)等有助于开发 AI 应用的功能。

GitHub 地址→
https://github.com/spring-projects/spring-ai

2.3 轻松托管网站和服务的开源平台:Coolify

主语言:PHP

Star:13k

周增长:800

这是一个免费、自托管、可替代 Heroku / Netlify / Vercel 等平台的开源项目,它提供了一个 Web 平台,用户可以在上面管理、部署各种 Web 应用和数据库服务,比如多种编程语言的动态网站、静态网页、WordPress、MongoDB、Redis 等。不挑服务器可以是自己的服务器,也可以是任意云服务器,只要支持 SSH 连接即可,服务器最低配置仅需 2C2G 即可。

GitHub 地址→
https://github.com/coollabsio/coolify

2.4 程序员的在线工具集合:it-tools

主语言:Vue

Star:9.9k

周增长:500

该项目采用 Vue.js(Vue 3)和 Naive UI 组件库开发,汇集了对开发人员和 IT 从业者有用的工具。它免费、界面清爽、功能丰富,支持包括中文在内的多国语言,提供了加密、转化器、网络、文本等开发常用工具。

GitHub 地址→
https://github.com/CorentinTh/it-tools

2.5 构建你的第二大脑:Quivr

主语言:TypeScript、Python

Star:31k

周增长:300

该项目利用生成式 AI 的能力,成为你的第二大脑。你可以将多种格式的文本、数据、语言和视频上传给它,之后再和它对话时,它会学习你上传的内容后回答你的问题,支持接入多种 LLM 和 Docker 一键部署。

GitHub 地址→
https://github.com/QuivrHQ/quivr

3. HelloGitHub 热评

在这个章节,将会分享下本周 HelloGitHub 网站上的热门开源项目,欢迎与我们分享你上手这些开源项目后的使用体验。

3.1 面向所有开发者的学习路线图:developer-roadmap

主语言:Other

这是一份包含后端、前端、运维部署等方向的学习路径图,提供了全面、实用、交互式的学习指南,解决开发者面临技术选型困难、自学路径不明晰等痛点。

项目详情→
https://hellogithub.com/repository/50587cd6e95e4fb0b3c787104028e9d2

3.2 假装很忙的摸鱼神器:genact

主语言:Rust

该项目可以在终端上模拟一些很忙的假象,比如编译、扫描、下载等。这些操作都是假的,实际上什么都没有发生,所以不会影响你的电脑,适用于 Windows、Linux、macOS 操作系统。

项目详情→
https://hellogithub.com/repository/6a78a0168ffc4a9f931b0ac1983aea9f

4. 结尾

无论是崇拜大神们的技术造诣,还是探索开源世界中的新奇工具,都希望大家可以从中获得启发和收获。如果看完这些还不过瘾,可以通过阅读往期回顾的内容,找到更多热门开源项目。

往期回顾

以上为本周的「GitHub 热点速递」全部内容,如果你发现其他好玩、有趣的 GitHub 项目,就来
HelloGitHub
和大家一起分享吧。

出来混总是要还的

最近在准备记录一个.NET Go核心能力的深度对比, 关于.NET/Go的异步实现总感觉没敲到点上。

async/await是.NET界老生常谈的话题,每至于此,状态机又是必聊的话题,但是状态机又是比较晦涩难懂的话题。

[一线码农大佬]在博客园2020年写的《
await,async 我要把它翻个底朝天,这回你总该明白了吧
》手把手实现了异步状态机,这篇文章很是经典, 但是评论区很多人还是在吐槽看不懂, 我也看的不是很懂。

以我浅薄的推测:

  1. 一线大佬的知识体系太宽太深,有的验证点在文字之外,需要我们自己去确认。
  2. 有些内容太细节,挖的太深,出不来。
  3. 很多人不熟悉
    状态机设计模式
    , 导致看大佬文章,知其然不知其所以然。

我以前用Go语言演示了状态机:
我是状态机,有一颗永远骚动的机器引擎
, 当时有粉丝留言让用.NET 实现状态机, 这篇文章也算是对粉丝的喊话。

状态机:一颗永远骚动的机器引擎

状态机是一种行为设计模式,它允许对象在其内部状态改变时改变其行为。看起来好像对象改变了它的类。

请仔细理解上面每一个字。

我们以自动售货机为例,为简化演示,我们假设自动售货机只有1种商品, 故自动售货机有
itemCount

itemPrice
2个属性

不考虑动作的前后相关性,自动售货机对外暴露4种行为:

  • 给自动售货机加货
    addItem
  • 选择商品
    requestItem
  • 付钱
    insertMoney
  • 出货
    dispenseItem

重点来了,当发生某种行为,自动售货机会进入如下4种状态之一, 并据此状态做出特定动作, 之后进入另外一种状态.....

  • 有商品
    hasItem
  • 无商品
    noItem
  • 已经选好商品
    itemRequested
  • 已付钱
    hasMoney

当对象可能处于多种不同的状态之一、根据传入的动作更改当前的状态, 继续接受后续动作,状态再次发生变化.....

这样的模式类比于机器引擎,周而复始的工作和状态转化,这也是状态机的定语叫“
机Machine
”的原因。

有了以上思路,我们尝试沟通UML 伪代码

状态机设计模式的伪代码实现:

  • 所谓的机器Machine维护了状态切换的上下文
  • 机器对外暴露的行为,驱动机器的状态变更
  • 机器到达特定的状态 只具备特定的行为,其他行为是不被允许的

Go版本的售货机(状态机设计模式)的源码,请参见原文https://www.cnblogs.com/JulianHuang/p/15304184.html。

async/await贴脸开大

还是以一线码农大佬的异步下载为例:

编译器词法分析定位到async/await语法糖,就会为开发者生成状态机代码。

一个新出炉的状态机包含如下属性 :


(1) 初始化的状态机,以async所在的函数名命名,示例状态机为
<GetResult>d__1

(2)车钥匙启动状态机之后,立马返回,这正是
异步编程
的内涵。

一个简单的、成功的状态机转化如图:

1. 初始状态

  • state= -1;
  • Start状态机; 即时返回。
Program.<GetResult>d__1 stateMachine = new Program.<GetResult>d__1();
stateMachine.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start<Program.<GetResult>d__1>(ref stateMachine);
return stateMachine.<>t__builder.Task;

车钥匙Start,内部实际是执行
MoveNext
方法


该方法会设置异步任务的
TaskAwaiter
对象, 紧接着stateMachine进入新的状态。

2. 异步任务未完成状态

int num1 = this.<>1__state;

if (num1 != 0)
{
    this.<client>5__1 = new WebClient();
    awaiter = this.<client>5__1.DownloadStringTaskAsync(new Uri("http://cnblogs.com")).GetAwaiter();
   if (!awaiter.IsCompleted)
  {
      this.<>1__state = num2 = 0;
      this.<>u__1 = awaiter;
      Program.<GetResult>d__1 stateMachine = this;
     this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter<string>, Program.<GetResult>d__1>(ref awaiter, ref stateMachine);
      return;
  }
}

IO数据就绪,会在IO线程执行回调方法
GetCompletionAction
,利用入参2:状态机,再次执行状态机的
MoveNext
方法, 进入新的状态

3. 异步结果就绪状态

  • 切换到state = -1;
  • taskAwaiter获取异步任务结果;
  • 执行后继代码;
else
{
    awaiter = this.<>u__1;
    this.<>u__1 = new TaskAwaiter<string>();
    this.<>1__state = num2 = -1;
}
this.<>s__3 = awaiter.GetResult();
this.<content>5__2 = this.<>s__3;
this.<>s__3 = (string) null;
content52 = this.<content>5__2;   // 后继代码段

若无异常,则进入状态机终止状态。

4. 状态机终止状态

  • 切换到state =-2;
  • 设置状态机最终返回值;
this.<>1__state = -2;
this.<client>5__1 = (WebClient) null;
this.<content>5__2 = (string) null;
this.<>t__builder.SetResult(content52);

以上四个状态的贴脸源码均截取自ILspy反编译结果,读者可将代码和状态轮转图对比。


一线码农大佬讲: 一个简单成功的async/await状态机会经历 2次
MoveNext
动作 ,我是认同的。

一次是状态机启动,主动切换状态;

第二次是IO数据就绪,回调函数会执行原状态机的
MoveNext
方法, 这个是在注册回调的时候确定的。

下面是第二次
MoveNext
方法的执行堆栈(包含github地址):

结束语

本文重点从状态机设计模式的角度,演示了async/await语法糖的内部实现。

通过一个骚动的机器引擎,演示了开启异步任务---> 异步任务完成---> 设置状态机输出结果的全过程,而这4个状态的变迁又催生了.NET异步编程的性能优势。

最后:本文是一线码农大佬(博客园12349粉丝博主)《异步async/await底朝天》的狗尾续貂,respect !!!