Go程序内存分配过多?试试本文的方法

发表于 3年以前  | 总阅读数:236 次

由于Go语言在抽象和垃圾回收内存管理模型方面介于C和Python之间,这对于那些希望能够找到一门处理效率高但同时又容易理解的高级程序语言的程序员来说,具有相当大的吸引力。然而,天下没有免费的午餐。Go 的抽象,特别是关于内存分配方面,是有代价的。此篇文章将展示并衡量和降低这一成本的方法。

测试案例

在 StackOverflow 和类似的论坛上有关性能的帖子中,会经常看到Knuth关于优化的一些名言:“过早的优化是万恶之源。” 这种简化忽略了原文引用[1]中一些重要的上下文:

由于程序员会过于追求效率导致浪费了大量时间来考虑或担心他们程序中非关键部分的速度,而在考虑调试和维护时,这些提高效率的尝试实际上会产生很大的负面影响。在97%的情况下,我们应该忽略一些小的效率优化:过早的优化是万恶之源。然而,我们不应该错过关键的 3% 的机会。一个优秀的程序员不会被这样的推理所迷惑,他会明智地仔细查看关键代码;但只有在代码被识别出来之后。

Knuth 强调,与其寻找小的性能提升,损害可读性,甚至不值得,我们应该寻找通过改进关键代码寻找大的 (97%) 提升。我们如何识别关键代码?使用分析工具。庆幸的是,Go 拥有比大多数语言更好的分析工具。例如,假设我们有以下(有些是刻意设计的)代码:

package main

import (
    "bytes"
    "crypto/sha256"
    "fmt"
    "math/rand"
    "strconv"
    "strings"
)

func foo(n int) string {
    var buf bytes.Buffer
    for i := 0; i < 100000; i++ {
        buf.WriteString(strconv.Itoa(n))
    }
    sum := sha256.Sum256(buf.Bytes())

    var b []byte
    for i := 0; i < int(sum[0]); i++ {
        x := sum[(i*7+1)%len(sum)] ^ sum[(i*5+3)%len(sum)]
        c := strings.Repeat("abcdefghijklmnopqrstuvwxyz", 10)[x]
        b = append(b, c)
    }
    return string(b)
}

func main() {
    // ensure function output is accurate
    if foo(12345) == "aajmtxaattdzsxnukawxwhmfotnm" {
        fmt.Println("Test PASS")
    } else {
        fmt.Println("Test FAIL")
    }

    for i := 0; i < 100; i++ {
        foo(rand.Int())
    }
}

接下来我们要加一下 CPU 分析的代码,改动如下

package main

import (
    "bytes"
    "crypto/sha256"
    "fmt"
    "math/rand"
    "os"
    "runtime/pprof"
    "strconv"
    "strings"
)

func foo(n int) string {
    var buf bytes.Buffer
    for i := 0; i < 100000; i++ {
        buf.WriteString(strconv.Itoa(n))
    }
    sum := sha256.Sum256(buf.Bytes())

    var b []byte
    for i := 0; i < int(sum[0]); i++ {
        x := sum[(i*7+1)%len(sum)] ^ sum[(i*5+3)%len(sum)]
        c := strings.Repeat("abcdefghijklmnopqrstuvwxyz", 10)[x]
        b = append(b, c)
    }
    return string(b)
}

func main() {
    cpufile, err := os.Create("cpu.pprof")
    if err != nil {
        panic(err)
    }
    err = pprof.StartCPUProfile(cpufile)
    if err != nil {
        panic(err)
    }
    defer cpufile.Close()
    defer pprof.StopCPUProfile()

    // ensure function output is accurate
    if foo(12345) == "aajmtxaattdzsxnukawxwhmfotnm" {
        fmt.Println("Test PASS")
    } else {
        fmt.Println("Test FAIL")
    }

    for i := 0; i < 100; i++ {
        foo(rand.Int())
    }
}

编译并运行此程序后,配置文件将会写入./cpu.pprof. 我们可以使用go tool pprof以下命令读取此文件:

$ go tool pprof cpu.pprof 

我们现在在 pprof 交互工具中。我们可以通过top10( top1, top2, top99, ...,topn也可以工作)看到我们的程序大部分时间都在做什么。top10展示如下:

Showing nodes accounting for 2010ms, 86.27% of 2330ms total
Dropped 48 nodes (cum <= 11.65ms)
Showing top 10 nodes out of 54
     flat  flat%   sum%       cum   cum%
     930ms 39.91% 39.91%      930ms 39.91%  crypto/sha256.block
     360ms 15.45% 55.36%      720ms 30.90%  strconv.formatBits
     180ms  7.73% 63.09%      390ms 16.74%  runtime.mallocgc
     170ms  7.30% 70.39%      170ms  7.30%  runtime.memmove
     100ms  4.29% 74.68%      100ms  4.29%  runtime.memclrNoHeapPointers
      80ms  3.43% 78.11%      340ms 14.59%  bytes.(*Buffer).WriteString
      60ms  2.58% 80.69%       60ms  2.58%  runtime.nextFreeFast (inline)
      50ms  2.15% 82.83%      360ms 15.45%  runtime.slicebytetostring
      40ms  1.72% 84.55%     2070ms 88.84%  main.foo
      40ms  1.72% 86.27%      760ms 32.62%  strconv.FormatInt

[ 注意:本文中使用“分配”指的是 堆分配[2] 。栈上分配也是分配,但在性能方面,它们并不是没有那么昂贵或重要。]

看起来我们在使用 sha256、strconv、内存分配和垃圾回收方面花费了大量时间。现在我们知道需要改进什么。由于我们没有进行任何类型的复杂计算(可能除了 sha256),我们的大多数性能问题似乎都是由堆分配引起的。我们可以通过替换来精确地验证一下内存分配

for i := 0; i < 100; i++ {
    foo(rand.Int())
}

fmt.Println("Allocs:", int(testing.AllocsPerRun(100, func() {
    foo(rand.Int())
})))

并导入testing包。当我们运行程序时,我们应该看到如下输出:

Test PASS
Allocs: 100158

几个月前,我的 雇主[3] 向我提出了将这个数字变为 0 的挑战。一开始这看起来很吓人,因为 10w的内存分配是很大的了。在本文中,我将展示我在减少此代码中的内存分配数时的思考过程和想法。

帕累托原则(Pareto Principle)

观察到分配的数量大约是 10w

for i := 0; i < 100000; i++ {
    buf.WriteString(strconv.Itoa(n))
}

我们将整数参数n作为字符串写入缓冲区 10w次。基准测试还表明这strconv.formatBits也需要花费大量时间。但是strconv.Itoa(n)每次遍历的值都不会改变。我们可以简单地计算一次:

x := strconv.Itoa(n)
for i := 0; i < 100000; i++ {
    buf.WriteString(x)
}

内存分配数大幅度降低:

Test PASS
Allocs: 159

寻找这样的机会应该是优化时的重中之重。我们已经去掉了大部分的分配,但是对于这样一个简单的函数来说,159 次的内存分配仍然很多。此外,如果我们查看 CPU 分析结果,我们仅将运行时间缩短了约 50%。如果我们能勉强维持剩余的分配,我们的运行时间可能小于 1s(开始是2.3s,目前是1.2s,在我的系统上)。

再次改进

我们已经取得了很大的进步,但我们还有一些路要走。我们继续从foo方法中从上往下看。

sum := sha256.Sum256(buf.Bytes())

如果我们查看 sha256[4] 包,Sum256 看起来是某种很快的方法。但是如果我们看一下 实现[5],代码如下 :

func Sum256(data []byte) [Size]byte {
    var d digest
    d.Reset()
    d.Write(data)
    return d.checkSum()
}

如果我们要使用该New函数创建一个新的哈希对象,似乎无论如何我们都必须这样做。如果有一种方法可以重用对象,也许这可能会很有用(提前剧透下:肯定有),但目前而言我们就有现成可用的。

接下来我们继续遍历

var b []byte
for i := 0; i < int(sum[0]); i++ {
    x := sum[(i*7+1)%len(sum)] ^ sum[(i*5+3)%len(sum)]
    c := strings.Repeat("abcdefghijklmnopqrstuvwxyz", 10)[x]
    b = append(b, c)
}

我们可以看到内存分配可能发生在b附加到缓冲区和strings.Repeat调用时,因为这可能涉及某种类型的复制。x这一行绝对不会内存分配,因为它只是从数组中读取单个整数并对它们执行一些计算。让我们来看看是否可以对strings.Repeat进行优化. 这里主要是在重复一个常量,所以我们可以展开重复的字符串,比如:

c := "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz"[x]

但这感觉有点粗糙。如果有更简单的方法会怎么样呢?假设x等于 53。如果我们找到字符串的第 53 个字符,我们会看到它是 'a'。第 54 位是“b”。第 79、105、(1+26k)th 也是“a”。注意到这里的规律了吧?由于字母表中有 26 个字符,因此我们可以用x对26取模得到相同的结果。

c := "abcdefghijklmnopqrstuvwxyz"[x%26]

现在内存分配的数量减少到了23个。

Test PASS
Allocs: 23

让我们回看一下字节切片b。开始是个空切片,append会分配新空间,所以这比最初仅分配一次要多。可以通过以下方式完成:

b := make([]byte, 0, int(sum[0]))
for i := 0; i < int(sum[0]); i++ {
    x := sum[(i*7+1)%len(sum)] ^ sum[(i*5+3)%len(sum)]
    c := "abcdefghijklmnopqrstuvwxyz"[x%26]
    b = append(b, c)
}

这下内存分配又减少到了19个。

现在剩下的就是return string(b)这条语句了。因为这会导致字节切片复制到字符串中,因此它会进行一次内存分配。我们可以使用下面这个指针转换的技巧来避免这种分配:

return *(*string)(unsafe.Pointer(&b))

这下又少了一次内存分配,到 18 了。

再看函数

现在我们已经完成了对函数的初始化,接下来让我们再看看是否还有其他的内存分配可以优化。

var buf bytes.Buffer
x := strconv.Itoa(n)
for i := 0; i < 100000; i++ {
    buf.WriteString(x)
}

看起来我们可以创建一个字节切片append到它后面,而不是创建一个 bytes.Buffer 。这样我们就分配了一次,而不必处理我们前面提到的重新分配。

所以我们可以将其替换为:

x := strconv.Itoa(n)
buf := make([]byte, 0, 100000*len(x))
for i := 0; i < 100000; i++ {
    buf = append(buf, x...)
}
sum := sha256.Sum256(buf)

现在只剩下 3 个分配了。如果我猜的话,这些应该是 创建buf、 创建bsha256.Sum256. 那么我们如何减少这些看似很棘手的分配呢?接下来sync.Pool该出场了。

sync.Pool [注意:以下部分与 Knuth描述的内容很相近。它主要是为了展示sync.Pool。然而,值得注意的是,虽然 3 次分配和 0 次分配之间的差异可能看起来不大,但实际上可能是因为这意味着不用GC(至少作为此函数的结果) .]

sync.Pool是 Go 中能分摊内存分配成本的一个特性。对于当前的代码,当我们分配缓冲区时,我们会在foo函数的生命周期内这样做。但是,如果我们能够分配一次,将其存储在一个全局变量中,然后无限地重用它(每次重置内容同时会保持容量)呢?手动执行此操作可能会有点复杂,也不会同时起作用。sync.Pool因此而生。sync.Pool允许您Get一个已分配的对象,Put并在用完之后放回去。如果池中没有可用的对象且如果你再请求Get一个,它会调用New函数来分配一个。接下来我们为哈希对象和哈希总和(256 字节切片)各创建一个对象池。放在上面的foo中:

var bufPool = sync.Pool{
    New: func() interface{} {
        // length of a sha256 hash
        b := make([]byte, 256)
        return &b
    },
}

var hashPool = sync.Pool{
    New: func() interface{} {
        return sha256.New()
    },
}

现在我们更新一下代码,将以下内容添加到foo中:

// get buffer from pool
bufptr := bufPool.Get().(*[]byte)
defer bufPool.Put(bufptr)
buf := *bufptr
// reset buf
buf = buf[:0]

// get hash object from pool
h := hashPool.Get().(hash.Hash)
defer hashPool.Put(h)
h.Reset()

我们用来sync.Pool.Get从池中得到一个对象,当函数返回时使用sync.Pool.Put将它放回池中。由于我们现在有一个 hasher 对象,所以我们可以直接向它写入而不是中间缓冲区。理想情况下,我们可以做类似的事情

x := strconv.Itoa(n)
for i := 0; i < 100000; i++ {
    h.Write(x)
}

不幸的是,Go 中的哈希对象没有 WriteString 方法,因此我们需要使用它strconv.AppendInt来获取字节切片。此外,使用 AppendInt 减少了内存分配,因为它写入的缓冲区buf是从bufPool中获取的而不是一个新分配的字符串,例如strconv.Itoa

x := strconv.AppendInt(buf, int64(n), 10)
for i := 0; i < 100000; i++ {
    h.Write(x)
}

现在我们可以获取哈希并将其放入buf中:

// reset whatever strconv.AppendInt put in the buf
buf = buf[:0]
sum := h.Sum(buf)

在下一个 for 循环中,我们从 0 迭代到sum[0],执行一些计算,并将结果放入b中。由于sum[0]永远不会超过 256(byte范围是 0-255),我们可以简单地说

b := make([]byte, 0, 256)

并保持循环内容不变。编译器可以在编译时用 256 推理,但不能用sum[0]. 这为我们减少了一次内存分配。

终于我们优化减少到 1 个分配。将最终的 return 语句替换为以下内容

sum = sum[:0] // reset sum
sum = append(sum, b...)
return *(*string)(unsafe.Pointer(&sum))

你会看到

Test PASS
Allocs: 0

为什么这样做?下面我们使用详细的逃逸分析信息来编译我们最初的内容,可能会有所帮助:

$ go build -gcflags='-m -m' a.go

...
./a.go:55:11: make([]byte, 0, 256) escapes to heap:
./a.go:55:11:   flow: b = &{storage for make([]byte, 0, 256)}:
./a.go:55:11:     from make([]byte, 0, 256) (spill) at ./a.go:55:11
./a.go:55:11:     from b := make([]byte, 0, 256) (assign) at ./a.go:55:4
./a.go:55:11:   flow: ~r1 = b:
./a.go:55:11:     from &b (address-of) at ./a.go:65:35
./a.go:55:11:     from *(*string)(unsafe.Pointer(&b)) (indirection) at ./a.go:65:9
./a.go:55:11:     from return *(*string)(unsafe.Pointer(&b)) (return) at ./a.go:65:2
./a.go:55:11: make([]byte, 0, 256) escapes to heap
...

新代码的输出如下:

...
./a.go:55:11: make([]byte, 0, 256) does not escape
...

如果我们将切片保留在函数中并且不返回它,它将被在栈上分配。任何 C 程序员都知道,分配在栈上的内存是不可能返回的。当 Go 编译器看到 return 语句时,切片会改为在堆上分配。但这并不能真正解释为什么将内容传输到sum不分配。sum不是也在堆上分配了吗?是的,但sync.Pool已经为我们在堆上分配了。

小结

  • unsafe 转换字符串/字节切片的技巧

  • 缓冲区不能修改,否则会panic!

  • 字符串 -> 字节切片: *(*[]byte)(unsafe.Pointer(&s))

  • 字节切片 -> 字符串: *(*string)(unsafe.Pointer(&buf))

  • 如果不依赖于循环中更新的状态,则不要在循环中运行该代码

  • 使用sync.Pool处理大的分配对象时

  • 对于字节切片,bytebufferpool[6] 可能更容易实现且拥有更高的性能

  • 如果可以,尽量重用缓冲区

  • bytes.Buffer.Reset

  • buf = buf[:0]

  • 重置缓冲区

  • 优先选择初始分配固定大小的数组,而不是不指定大小并将其留给 增长因子[7]

  • 确保实际情况下对自己的代码进行 基准测试[8] ,看看更改是否可以提高性能/权衡可读性问题

结论

性能确实是很重要的,但是并不总是很难改进。尽管这段代码有点刻意人为,但可以从改进其性能的过程中吸取一些教训。

参考资料

[1] 原文引用: chrome-extension://cdonnmffkdaoajfknoeeecmchibpmkmg/assets/pdf/web/viewer.html?file=https%3A%2F%2Fweb.archive.org%2Fweb%2F20210425190711if_%2Fhttps%3A%2F%2Fpic.plover.com%2Fknuth-GOTO.pdf

[2] 注意:本文中使用“分配”指的是 [堆分配: https://www.sciencedirect.com/topics/computer-science/heap-allocation

[3] 雇主: https://github.com/lukechampine

[4] sha256: https://golang.org/pkg/crypto/sha256/

[5] 实现: https://golang.org/src/crypto/sha256/sha256.go?s=5634:5669#L244

[6] bytebufferpool: https://github.com/valyala/bytebufferpool

[7] 增长因子: https://forum.golangbridge.org/t/slice-append-what-is-the-actual-resize-factor/15654

[8] 基准测试: https://golang.org/pkg/testing/#hdr-Benchmarks

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/jtkjdaWlgaguxJrPE2ifug

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237328次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8175次阅读
 目录