由于Unbinilium的Twist脚本长时间不更新且已删库,想自己维护但确实不太会写shell脚本,只好把他脚本里的内容一步步写下来,当作日记,也方便自己日后使用。

前置准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 在tmp目录操作
cd /tmp

# 创建备份目录
sudo mkdir /etc/twist
# 文件格式:filename.old-$(date +%Y%m%d%H%M%S)

sudo apt update
sudo apt upgrade

# wget、curl下载可执行文件,git下载仓库
# nginx用于伪装,fail2ban用来ban恶意IP
# 其他都是一些编译、运行依赖的,或者是一些实用工具
sudo apt install -y wget gawk grep curl sed git gcc swig gettext autoconf automake make libtool perl cpio xmlto asciidoc cron net-tools dnsutils rng-tools libc-ares-dev libev-dev openssl libssl-dev zlib1g-dev libpcre3-dev libevent-dev build-essential python3-dev python3-pip python3-setuptools python3-qrcode nginx fail2ban

# 检测并获取服务器的出口网卡名称
# ETH="$(route | grep '^default' | grep -o '[^ ]*$')"
route | grep '^default' | grep -o '[^ ]*$'
# eth0

# 没有就再执行
# ETH="$(ip -4 route list 0/0 | grep -Po '(?<=dev )(\S+)')"
ip -4 route list 0/0 | grep -Po '(?<=dev )(\S+)'
# eth0

# 查看eth0网卡当前状态
cat /sys/class/net/eth0/operstate
# up/down

# 获取服务器公网IPv4地址
dig @resolver1.opendns.com -t A -4 myip.opendns.com +short

# 查看是否有IPv6地址
ip -6 addr show eth0

# 获取服务器公网IPv6地址
curl -s diagnostic.opendns.com/myip

# 获取mtu数值
cat /sys/class/net/eth0/mtu
# 一般是1500,如果没有输出,后面就把mtu设置为1492
阅读全文 »

工作依赖Win7共享文件夹,经常抽风死机,需要人工手动重启。想要将其迁移至Ubuntu。

后话:实践证明,ntfs挂载方式+wps linux办公会很慢,不合适。另外,wps linux居然不支持emoji

安装Samba

1
sudo apt install samba

创建共享目录

找到磁盘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
sudo fdisk -l

# 如果是Linux的ext4文件系统

# Disk /dev/sda:931.51 GiB,1000204886016 字节,1953525168 个扇区
# 磁盘型号:WDC WD10SPZX-22Z
# 单元:扇区 / 1 * 512 = 512 字节
# 扇区大小(逻辑/物理):512 字节 / 4096 字节
# I/O 大小(最小/最佳):4096 字节 / 4096 字节
# 磁盘标签类型:gpt
# 磁盘标识符:F22FBE2A-71CB-4618-B90B-C7E4928AFDE5

# 设备 起点 末尾 扇区 大小 类型
# /dev/sda1 2048 1953523711 1953521664 931.5G Linux 文件系统

# 如果是Windows的NTFS文件系统

# Disk /dev/sda:931.51 GiB,1000204886016 字节,1953525168 个扇区
# 磁盘型号:WDC WD10SPZX-22Z
# 单元:扇区 / 1 * 512 = 512 字节
# 扇区大小(逻辑/物理):512 字节 / 4096 字节
# I/O 大小(最小/最佳):4096 字节 / 4096 字节
# 磁盘标签类型:dos
# 磁盘标识符:0xd88aa425

# 设备 启动 起点 末尾 扇区 大小 Id 类型
# /dev/sda1 2048 1953523711 1953521664 931.5G f W95 扩展 (LBA)
# /dev/sda5 4096 1953523711 1953519616 931.5G 7 HPFS/NTFS/exFAT

记录该设备ID

阅读全文 »

数据协议

socks5

握手阶段

1
2
3
4
5
6
7
8
//    client send
//
// +----+----------+----------+
// |VER | NMETHODS | METHODS |
// +----+----------+----------+
// | 1 | 1 | 1 to 255 |
// +----+----------+----------+
//

客户端发起握手请求,数据格式如上图所示,具体如下

  • VER:固定0x05,表示socks5协议
  • NMETHODS:表示支持认证的方法数量,也是后面的METHODS字段占用的字节数
  • METHODS:认证方法列表,一个协议占用一个字节,其中一些常见的协议如下
    • 0x00:不需要认证
    • 0x01:GSSAPI
    • 0x02:用户名、密码认证
    • 0x03~0x7F:保留
    • 0x80~0xFE:私有
    • 0xFF:无可接受的方法
1
2
3
4
5
6
7
8
// 
// server send
// +----+--------+
// |VER | METHOD |
// +----+--------+
// | 1 | 1 |
// +----+--------+
//

服务端响应内容如上图所示,具体如下

  • VER:固定0x01
  • METHOD:认证方法,在shadowsocks源码里,选择0x00-不需要认证
阅读全文 »

go的垃圾回收原理官方说法是是三色标记法+混合写屏障,但是,理论上怎么说是一回事,具体实现又是另一回事了,或者说实现已经偏离文档描述。总的来说,GC这部份的内容要比GMP跟内存分配都要复杂的多且出人意料。当前go版本:1.24

前言

目标对象

Go的GC并不扫描整个内存,只关注以下区域:

  1. heap上分配的对象(在mspan管理范围)
  2. data/bss段的全局变量
  3. 栈上的引用(扫描stackRoots)

触发类型

触发类型 备注
gcTriggerHeap 堆大小触发,heap内存达到一个临界点触发(最常用)
gcTriggerTime 时间触发,超时2min未执行GC则强制执行
gcTriggerCycle 手动触发,用户调用runtime.GC()

角色/分工

角色 作用 工作阶段 备注
Mark Worker(标记工作线程) 遍历对象图,标记存活对象,防止被错误回收 标记阶段(Marking) 运行时创建多个并发 Mark Worker,加快标记速度
Sweeper(清扫器) 清理未标记的对象,将其内存释放回空闲列表(mheap.free) 清扫阶段(Sweeping) 逐步清理,避免一次性 STW(Stop The World)
Scavenger(内存回收器) 释放长期未使用的堆内存,归还给OS以减少RSS 后台运行(定期触发) 主要针对大对象或空闲mspans,减少物理内存占用

标记工作线程

其中,标记工作线程的会根据工作模式进一步区分,如下

标记工作线程模式 备注
gcMarkWorkerNotWorker 默认,未运行
gcMarkWorkerDedicatedMode 专用标记任务,最高优先级,非抢占
gcMarkWorkerFractionalMode 比例标记任务,跟其他g共享时间,可被抢占
gcMarkWorkerIdleMode 空闲时执行的低优先级标记任务,需要p空闲

CPU限制

标记工作线程的CPU使用率被限制在25%(GOGC=100时),假设系统使用一个6核CPU,那么GC在标记阶段大约会使用1.5个CPU资源:

  1. 1个CPU由Dedicated(专用模式)的标记工作线程持续占用,该线程不允许抢占,直到标记任务完成
  2. 0.5个CPU由Fractional(比例模式)的标记工作线程使用,该线程仅在由额外CPU资源可用时运行,并会根据系统负责动态调整自身的CPU使用率

完整运行流程

  1. Sweep Termination(清理终止)

    • STW(Stop The World),确保所有P都达到GC安全点
    • 完成上一轮GC未完成的sweep(清扫),回收剩余的的mspan
    • 准备GC统计数据,为新一轮的GC计算目标heap大小、触发阈值等
  2. Mark(标记)

    • STW,切换GC状态
      • gcphase从_GCoff切换到_GCmark
      • 开启写屏障(write barrier),允许Mutator协助GC标记,以维护三色标记不变性
      • 启动GC后台线程,执行并发标记任务
      • 根对象入队(包括栈、全局变量)
    • 恢复世界(Start The World),GC线程进入并发标记阶段
      • 从根对象开始标记,遍历所有可达对象
      • 扫描灰色对象(已发现但未完全扫描的对象)并进行扫描,将其置黑,并将其引用的对象入队为灰色
      • 混合写屏障(Hybrid Write Barrier)确保一致性
    • 完成标记
  3. Mark Termination(标记终止)

    • STW,切换GC状态
      • gcphase从_GCmark切换到_GCmarktermination
      • 停止并发标记任务
      • 执行终结器finalizer,如果有的话
      • 清理mcache以确保没有悬挂对象
  4. Sweep(清理)

    • 切换GC状态
      • gcphase从_GCmarktermination切换回_GCoff
      • 关闭写屏障(Mutator不再协助GC标记)
    • 恢复世界(Start The World),进入并发清扫阶段
      • 清理未被标记的对象
      • 回收mspan到mheap或mcentral,部份回收到mcache
      • Mutator分配内存时,可能会触发增量清扫(Incremental Sweeping),加快回收过程
  5. 满足触发条件,启动下一轮GC

简单的说,标记然后清扫

阅读全文 »

golang的内存分配机制最初是基于TCMalloc,演化至今已经有了很大差异。其原理是:slab + tiling algorithm + 层级内存分配。本文仅介绍如何通过mallocgc分配内存,不涉及栈内存分配管理、手动内存管理等内容。当前go版本:1.24

前言

slab + tiling

内存分配基本单位-mspan(即slab),内部再划分更小的块(即tiling)

小对象(<=32KB)按预定义的sizeclass分成不同的mspan,每个mspan最低有8KB,切割成指定大小的块。而大对象(>32KB)的sizeclass=0,不限制大小,mspan不分块。如下

1
2
3
4
5
6
7
8
// mspann -> sizeclass=0  -> nKB  -> | <-----   nKB   -----> |
// ...
// mspanx -> sizeclass=1 -> 8KB -> | 8B | 8B | ... | 8B |
// ...
// mspany -> sizeclass=5 -> 8KB -> | 48B | 48B | ... | 48B |
// ...
// mspanz -> sizeclass=65 -> 80KB
// ...

make([]int, 5),创建一块40B大小的内存区域,经过计算这个内存块会从sizeclass=5的mspan分配(该mspan的每个元素大小为48B)

层级内存分配

参考TCMalloc,内存分配时主要分为3级:mcache、mcentral、mheap

  1. mcache为线程缓存,每个p都有一个,所有的内存分配/回收都是先通过mcache,访问不需要加锁
  2. mcentral负责向mheap申请分配内存、管理mspan,按spanclass分组,嵌入到mheap结构体,全局唯一
  3. mheap负责从系统申请内存进行分配管理,一般情况下访问都要加锁,全局唯一

执行mallocgc分配内存时,mheap、mcentral、mcache的关系如下

1
2
3
4
5
6
7
//  mallocgc
// |
// | (<=32KB)
// |--> p.mcache.alloc[spanclass] --> mheap.central[spanclass] --> mheap
// |
// | (>32KB)
// |--> mheap
  • 如果对象<=32KB
    • 从p的mcache找一个空闲的mspan分配一段内存/地址空间
    • 如果mspan没有空闲空间,从mcentral申请一个新的/重用一个已清扫的mspan
    • 如果mcentral也没有可用mspan,通过mheap跟OS申请一段内存,初始化一个mspan返回
  • 如果对象>32KB
    • 通过mheap跟OS申请一段内存,初始化一个mspan返回

系统内存状态

这里需要了解系统内存的几个状态,以及go内部是如何从系统分配、释放内存的

状态 含义
None 默认状态,未映射,地址空间未被保留或使用
Reserved 已保留,但未提交,即地址空间已经被申请,但尚未向操作系统请求实际物理内存
Prepared 已提交但未使用,已经向操作系统申请了物理内存,但可能未完全初始化
Ready 可用状态,内存已初始化,可用于分配

状态转换函数

函数 主要作用 是否分配物理内存 是否映射虚拟内存 内存状态转换 备注
sysAlloc 直接申请内存 ✅ 是 ✅ 是 None -> Ready 可能会触发 mmap
sysReserve 保留虚拟地址空间,但不映射 ❌ 否 ✅ 是 None -> Reserved 预留地址,后续 sysMap
sysMap 将预留的虚拟地址映射为实际物理内存 ✅ 是 ✅ 是 Reserved -> Prepared 只有 sysReserve 过的地址能 sysMap
sysUsed 标记某段地址正在使用 ✅ 是 ✅ 是 Prepared -> Ready 可能会触发 madvise 让物理页生效
sysUnused 标记某段地址未使用,可以回收物理内存 ⚠️ 可能 ❌ 否 Ready -> Prepared MADV_DONTNEED,内存仍属于进程
sysFault 让一段地址变成不可访问 ❌ 否 ✅ 是 Ready -> Reserved mprotect(PROT_NONE),用于调试
sysFree 释放虚拟内存,归还给 OS ✅ 是 ✅ 是 -> None munmap,这段内存不能再用

我对Go内存分配的理解

  1. 内存管理的本质是地址空间的组织和维护
  2. Go的内存策略:尽量保留虚拟地址,按需释放物理页
  3. Go会一次性申请64MB内存(Reserved-虚拟地址空间),但并不是立即分配物理页,而是在需要分配时使用sysMap + sysUsed使一小部份内存可用(Reserved->Prepared->Ready)

GC助攻

为了防止内存分配速度过快,导致GC跟不上,分配时会判断是否需要协助GC标记/清扫。每次都要判断是否需要协助标记,而清扫只发生在获取新的mspan和大对象分配场景下

阅读全文 »

本文仅介绍程序运行流程以及GMP如何寻找g并运行,其他如抢占、死锁、信号处理、profiling等内容不打算深入。当前go版本:1.24

前提

先讲几个概念

进程、线程、协程

  1. 进程:程序的一个实例,也是操作系统的一个task,操作系统的资源分配最小单位
  2. 线程:一种概念,操作系统调度的最小单位,一个进程可以包含多个线程,线程之间共享内存、文件描述符等资源。不同操作系统的实现并不一致,linux下进程与线程的结构都是task_structure,也就是说他们是一样的,不同线程之间用指针指向同一份资源如内存空间实现共享。
  3. 协程:也称为用户态线程,由应用程序自行实现/调度,一般情况下是协作式的,由开发者决定task何时让出cpu,go支持抢占式调度

线程与协程的映射模型

  1. 1:1模型,每个用户线程对应一个内核线程,如在c中pthread创建的线程,此时也可以理解为用户态线程就是内核态线程
  2. 1:N模型,一个内核线程对应多个用户线程,无法充分利用多核的并行性,现已淘汰
  3. M:N模型,多个用户线程对应多个内核线程,实现较复杂,使用者较少,go是其中一个

GMP模型

go早期的M:N模型遇到一些性能问题,如锁竞争激烈、线程创建/销毁频繁、CPU缓存失效等,为了解决这些问题引入了P。在GMP模型中

  1. G-goroutine,用户态线程
  2. M-machine,系统线程相关
  3. P-processor,缓存、调度上下文等,其数量一般与CPU核心数量一致。P的出现使得调度变得本地化,避免全局锁竞争,提升了CPU缓存命中率等,最终使得go的并发调度更加高效

go程序的运行流程

go程序启动时的入口是_rt0_amd64,该函数是汇编代码,具体如下

1
2
3
4
5
6
// src/runtime/asm_amd64.s
// 系统入口点
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP runtime·rt0_go(SB)

runtime·rt0_go也是汇编代码,比较长,主要逻辑如下

  1. g0、m0双向绑定(g0、m0是全局变量,静态编译,因此指针已知,放在src/runtime/proc.go)
  2. runtime·args - 复制命令行参数(args函数放在src/runtime/runtime1.go)
  3. runtime·osinit - 系统初始化(osinit函数放在src/runtime/os_linux.go)
  4. runtime·schedinit - 调度器初始化(schedinit函数放在src/runtime/proc.go)
  5. runtime·mainPC - 纪录runtime·main的地址(main函数放在src/runtime/proc.go,其内部调用main_main,也就是用户自己编写的main函数)
  6. runtime·newproc - 创建G用于运行runtime·main,放到p的runq里,等待调度
  7. runtime·mstart - 运行runtime·mstart(汇编函数,实际调用的是runtime·mstart0,放在src/runtime/proc.go,内部进行栈初始化、信号注册等,最后运行调度函数schedule)
阅读全文 »

Timer-一次性定时器,Ticker-周期性定时器。从1.23版本开始,将异步实现改为同步实现,但你仍然可以使用AfterFunc创建异步定时器,或者通过改变asynctimerchan变量启用异步实现

asynctimerchan变量可选项如下

asynctimerchan description
0 同步实现,从1.23版本开始启用
1 旧版异步实现
2 同1,异步实现,但修复了1的问题,debug用

定时器的精确度因系统不同而不同,具体如下

OS resolution
Unix ~1ms
>= Windows 1803 ~0.5ms
< Windows 1803 ~16ms

快速上手

深入了解源代码前,先了解其功能如何使用

Timer-一次性定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"time"
)

func main() {
// 定时器1
timer1 := time.NewTimer(2 * time.Second)

<-timer1.C
fmt.Println("Timer 1 fired")

// 定时器2
timer2 := time.NewTimer(time.Second)
go func() {
<-timer2.C
fmt.Println("Timer 2 fired")
}()
stop2 := timer2.Stop()
if stop2 {
fmt.Println("Timer 2 stopped")
}

// 定时器3
time.Sleep(2 * time.Second)
}

上述示例代码运行效果如下

1
2
3
4
go run main.go

# Timer 1 fired
# Timer 2 stopped

Ticker-周期性定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"fmt"
"time"
)

func main() {
// 每500ms执行一次
ticker := time.NewTicker(500 * time.Millisecond)
done := make(chan bool)

go func() {
for {
select {
case <-done:
return
case t := <-ticker.C:
fmt.Println("Tick at", t)
}
}
}()

// 挂起休眠1600ms
time.Sleep(1600 * time.Millisecond)
// 停止定时器
ticker.Stop()
done <- true
fmt.Println("Ticker stopped")
}

上述示例代码运行效果如下

1
2
3
4
5
6
go run main.go

# Tick at 2025-02-27 10:41:07.875146141 +0800 CST m=+0.500099485
# Tick at 2025-02-27 10:41:08.37515345 +0800 CST m=+1.000100767
# Tick at 2025-02-27 10:41:08.875159521 +0800 CST m=+1.500100789
# Ticker stopped
阅读全文 »

netpoll是golang用来处理网络I/O事件的底层机制,主要通过操作系统的I/O多路复用机制如Linux的epoll、BSD的kqueue、Windows的IOCP等来实现

数据结构

核心的数据结构是pollDesc,用于存储与文件描述符相关的事件数据,一般被放入如epoll的epoll_event.data来传递信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
type pollDesc struct {
_ sys.NotInHeap // 放置转换成interface{}时申请heap内存
link *pollDesc // next指针,用于pollcache链表
fd uintptr // 文件描述符
fdseq atomic.Uintptr // 计数器,类似时间戳,确保过期的消息不会被处理,只在获取/放回cache时改变
atomicInfo atomic.Uint32 // 5个状态位+fdseq(这两个数据有位交叉冲突,没搞懂)
rg atomic.Uintptr // 读状态,读G的地址也作为一种状态
wg atomic.Uintptr // 写状态,写G的地址也作为一种状态
lock mutex // 锁,保护下列字段
closing bool // 是否被移除出netpoll
rrun bool // rt-读定时器是否在运行
wrun bool // wt-写定时器是否在运行
user uint32 // cookie,linux/bsd应该没用到
rseq uintptr // 读计数器,类似fdseq,只有获取/放回cache以及设置deadline时改变
rt timer // 读定时器
rd int64 // 读过期时刻,-1为已过期
wseq uintptr // 写计数器,类似fdseq,只有获取/放回cache以及设置deadline时改变
wt timer // 写定时器
wd int64 // 写过期时刻,-1为已过期
self *pollDesc // 当前实例指针
}

// pollDesc缓存,重复使用,避免反复申请内存
type pollCache struct {
lock mutex // 锁
first *pollDesc // 链表头部指针,pollDesc指针都从头部写入 new -> old -> ...
}

pollDesc部份字段讲解如下

  1. atomicInfo是一个无符号32位整型数,每位用途如下
16bit 11bit 1bit 1bit 1bit 1bit 1bit
fdseq unused pollFDSeq pollExpiredWriteDeadline pollExpiredReadDeadline pollEventErr pollClosing

注意:fdseq占据20位数据,但在atomicInfo里,fdseq要向左移位16位,看起来是数据丢失了,没搞明白。同样有问题的还有taggedPointerPack

  1. rgwg的状态列表如下
state_name state_val description
pdNil 0 默认值
pdReady 1 io可读,下一个状态是pdNil
pdWait 2 准备挂起,下一个状态是G pointer-挂起,pdReady-io可读,pdNil-超时/关闭
G pointer 0xabc goroutine指针-挂起,下一个状态是pdReady-io可读,pdNil-超时/关闭
阅读全文 »

time涉及的内容较多,如生成/存储时间、比较时间、获取时间信息、时区、夏令时等,本文仅介绍一些自己感兴趣的地方

日历计算基于格里高利历(公历),1年有365天(闰年有366天)。同时支持墙上时钟(wall clock)和单调时钟(monotonic clock),其中墙上时钟用于时间同步、报时(time-telling),单调时钟用于时间测量(time-measuring)。并不是所有函数都支持单调时钟,如字符串编码/解码函数就会舍弃单调时钟数据

注意:

  1. 时间精确度:纳秒
  2. 大部分都是线程安全,除了
    • GobDecode
    • UnmarshalBinary
    • UnmarshalJSON
    • UnmarshalText
  3. 有些系统会在进程休眠时停止单调时钟,会导致一些函数计算异常,如
    • Sub
    • Since
    • Until
    • Before
    • After
    • Add
    • Equal
    • Compare
  4. 字符串编码时,保存的是Location的offset,会导致dst-夏令时丢失,相关函数
    • GobEncode
    • MarshalBinary
    • AppendBinary
    • MarshalJSON
    • MarshalText
    • AppendText
  5. 字符串编码/解码时,会丢弃单调时钟信息

当前go版本:1.24

快速上手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
"fmt"
"time"
)

func main() {
p := fmt.Println

// 当前时刻
now := time.Now()
p(now)

// 指定时刻
then := time.Date(
2009, 11, 17, 20, 34, 58, 651387237, time.UTC)
p(then)

p(then.Year()) // 年
p(then.Month()) // 月
p(then.Day()) // 日
p(then.Hour()) // 时
p(then.Minute()) // 分
p(then.Second()) // 秒
p(then.Nanosecond()) // 纳秒
p(then.Location()) // 时区

p(then.Weekday()) // 星期

// 判断两个时刻先后顺序
p(then.Before(now))
p(then.After(now))
p(then.Equal(now))

// 时长/时刻差值
diff := now.Sub(then)
p(diff)

p(diff.Hours()) // 转换成总小时数
p(diff.Minutes()) // 转换成总分钟数
p(diff.Seconds()) // 转换成总秒数
p(diff.Nanoseconds()) // 转换成总纳秒数

p(then.Add(diff))
p(then.Add(-diff))
}

运行结果如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
go run main.go

# 2025-02-22 10:30:14.247195 +0800 CST m=+0.000103986 # 当前时刻
# 2009-11-17 20:34:58.651387237 +0000 UTC # 指定时刻
# 2009 # 年
# November # 月
# 17 # 日
# 20 # 时
# 34 # 分
# 58 # 秒
# 651387237 # 纳秒
# UTC # 时区
# Tuesday # 星期
# true
# false
# false
# 133805h55m15.595807763s # 时长/时刻差值
# 133805.9209988355 # 总小时数
# 8.028355259930129e+06 # 总分钟数
# 4.817013155958078e+08 # 总秒数
# 481701315595807763 # 总纳秒数
# 2025-02-22 02:30:14.247195 +0000 UTC
# 1994-08-13 14:39:43.055579474 +0000 UTC
阅读全文 »
0%