普通视图

发现新文章,点击刷新页面。
昨天以前Mark24Code

为什么每个人都讨厌 fork(2) ?

2025年2月8日 17:26

我想写一篇关于 Pitchfork 的文章,解释它的起源、为什么它会是现在这个样子,以及我对其未来的看法。但在达到这一点之前,我认为我需要解释一些事情,fork 被认为是一种过时的旧物,甚至可以说是“恶魔的创造”。然而,在 Ruby 生态系统中,它却无处不在。

请注意,如果您有一些系统编程经验,您在这里可能学不到太多。

如果您曾经部署过 Ruby 应用程序到生产环境,那么您几乎肯定已经与 fork(2) 打过交道,无论您是否意识到。您是否配置过 Puma 的 worker 设置?嗯,Puma 使用 fork(2) 来启动这些工作进程,更准确地说,是 Ruby 的 Process.fork 方法,这是 Ruby API 对底层 fork(2) 系统调用的封装。

即使你不是 Ruby 开发者,如果你使用过 PHP、Nginx、Apache HTTPd、Redis 等,你也使用了一个高度依赖 fork(2) 的系统。

然而,许多人会认为 fork(2) 是邪恶的,不应该被使用。我个人有点既同意又不同意这种观点,我将尝试解释原因。

一点历史

根据维基百科,fork 概念首次出现可以追溯到 1962 年,由提出康威定律(Conway’s law)的同一个人提出,后来在 UNIX 的第一个版本中引入。

最初,它被设计为一种用于创建新进程的原语。你会调用 fork(2) 来复制当前进程,然后从那里开始将这个新进程修改为你想要的样子,紧接着调用 exec(2) ,我们一般这样使用。直到今天,你仍然可以在 Ruby 中这样做。

译者注:一切不懂这方面的读者可能觉得莫名其妙。这地方介绍的不清楚,我补充一点。 这是 Unix/Linux 操作系统,创建子进程的标准方法。先调用 fork 这样会迅速的从当前进程复制一份出来,然后紧接着执行 exec 传入具体 bash 的命令。这样就创建了一个子进程,并且关联了当前进程为父进程。exec 执行内容会占据 之前 fork 的进程,作为一个独立进程执行。 这样设计存在历史原因,也相当聪明,等于复制模版,再更改模板内的内容。

if (child_pid = Process.fork)
  # We're in the parent process, and we know the child process ID.
  # We can wait for the child to exit or send signals etc.
  Process.wait(child_pid)
else
  # We're in the child process.
  # We can change the current user and other attributes.
  Process.uid = 1
  # And then we can replace the current program with another.
  Process.exec("echo", "hello")
end

从某种意义上说,这种设计相当优雅。你拥有少量简单的基础组件,可以将它们组合在一起以获得你所需要的精确行为,而不是一个庞大的函数,需要传递无数个参数。

但这种方法也非常低效,因为完全复制一个进程来创建一个新的进程通常是小题大做。在上面的例子中,如果你想象我们的父程序有数GB字节的可寻址内存,那么将所有这些内存复制过来,然后几乎立刻将其全部丢弃,以便用一个极其小的程序(如/bin/echo)来替换,这是一种巨大的浪费。

当然,现代操作系统实际上并不会复制所有这些内容,而是使用写时复制(Copy-on-Write),但这仍然非常昂贵,如果父进程很大,很容易就需要数百毫秒。

这是因为使用 fork(2) 来启动其他程序的历史用法现在大多被认为是过时的,大多数新的软件将使用更现代的 API,如 posix_spawn(3)vfork(2)+exec(2)

fork(2) 的用途并不仅限于此。我不知道这是否从一开始就被设计好了,还是后来才逐渐形成的一种用法,但我前面提到的所有软件都使用了 fork(2),而且从未在其后调用过 exec(2)

Fork 作为并行原语

再次,我甚至不是在七十年代初出生的,所以我不太确定这种做法究竟是从什么时候开始的,但某个时候 fork(2) 开始被用作并行性原语,尤其是在服务器方面。

让我们假设您想从头开始实现一个简单的“echo”服务器,在 Ruby 中可能看起来像这样:

require 'socket'

server = TCPServer.new('localhost', 8000)

while socket = server.accept
  while line = socket.gets
    socket.write(line)
  end
  socket.close
end

该脚本首先在端口 8000 上打开一个监听套接字,然后阻塞在 accept(2) 系统调用上等待客户端连接。当该方法返回时,它给我们一个双向套接字,我们可以从中读取,在这种情况下使用 #gets ,也可以向客户端写回。

虽然这使用了现代 Ruby,那与当时各种服务器的编写方式非常相似,但过于简化。

如果您想玩它,可以使用 telnet localhost 8000 开始编写内容。

但是那个服务器有一个大问题:它只支持单个并发用户。如果你尝试同时开启两个 telnet 会话,你会看到第二个无法连接。

所以人们开始利用 fork(2) 来支持更多用户:

require 'socket'

server = TCPServer.new('localhost', 8000)
children = []

while socket = server.accept
  # prune exited children
  children.reject! { |pid| Process.wait(pid, Process::WNOHANG)}

  if (child_pid = Process.fork)
    children << child_pid
    socket.close
  else
    while line = socket.gets
      socket.write(line)
    end
    socket.close
    Process.exit(0)
  end
end

逻辑与之前相同,但现在一旦 accept(2) 返回一个套接字,我们不再在它上面阻塞,而是 fork(2) 一个新的子进程,并让那个子进程执行阻塞操作,直到客户端关闭连接。

如果您是一位敏锐的读者(或者您已经对 fork(2) 语义有所了解),您可能已经注意到在调用 fork 之后,父进程和新的子进程都可以访问套接字。这是因为,在 UNIX 中,套接字是“文件”,因此由“文件描述符”表示,而 fork(2) 语义的一部分是所有文件描述符都可以继承。

这就是为什么重要的是让父进程关闭套接字,否则,它将在父进程中永远保持打开状态(技术上,一旦对象被垃圾回收,Ruby 会自动关闭它,但你明白了这个意思),这也是许多人讨厌 fork(2) 的第一个原因之一。

一把双刃剑

如上所示,子进程继承所有打开的文件描述符的事实允许实现一些非常有用的事情,但如果你忘记关闭一个你不想共享的文件描述符,这也可能导致灾难性的错误。

例如,如果您正在 fork 一个与 SQL 数据库有活动连接的进程,并且您在两个进程中都继续使用该连接,会发生奇怪的事情:

require "bundler/inline"
gemfile do
  gem "trilogy"
  gem "bigdecimal" # for trilogy
end

client = Trilogy.new
client.ping

if child_pid = Process.fork
  sleep 0.1 # Give some time to the child

  5.times do |i|
    p client.query("SELECT #{i}").first[0]
  end
  Process.kill(:KILL, child_pid)
  Process.wait(child_pid)
else
  loop do
    client.query('SELECT "oops"')
  end
end

这里脚本使用 trilogy 客户端连接到 MySQL,然后在一个循环中无限查询 SELECT “oops” ,然后创建一个子进程。一旦子进程被创建,父进程发出 5 个查询,每个查询应该返回一个从 0 到 4 的单个数字,并打印其结果。

如果您运行此脚本,您将得到一些随机的输出,类似于这样:

"oops"
1
"oops"
"oops"
3

这里发生的情况是,两个进程都在同一个套接字内写入。对于 MySQL 服务器来说,这不是什么大问题,因为我们的查询很小,所以它们会被“原子性地”写入套接字。如果我们发出更大的查询,两个查询可能会交错,这会导致服务器以某种协议错误的形式关闭连接。

但是对客户来说,这真的很糟糕。因为两个进程的响应都通过同一个套接字发送回来,每个客户端都在发出 read(2) ,可能会收到它刚刚发出的查询的响应,但也可能收到另一个由其他进程发出的无关查询的响应。

当两个进程尝试在同一个套接字上 read(2) 时,它们各自获取部分数据,但你无法正确控制哪个进程获取什么,尝试同步这两个进程以使它们各自获得预期的响应是不切实际的。

考虑到这一点,你可以想象在调用 fork(2) 之前正确关闭应用程序的所有套接字和其他打开的文件会有多大的麻烦。也许你在自己的代码中会非常勤奋,但你可能正在使用一些可能不会期望调用 fork(2) 并且不允许你关闭它们的文件描述符的库。

对于 fork+exec 用例,有一个很棒的功能让这变得容易得多,你可以在调用 exec 时标记一个文件描述符需要关闭,操作系统会为你处理这个, O_CLOEXEC (在 exec 时关闭),在 Ruby 中方便地作为 IO 类上的一个方法公开:

STDIN.close_on_exec = true

但是,当它后面没有跟随着一个 exec 时, fork 系统调用就没有这样的标志。或者更准确地说,有一个, O_CLOFORK ,它存在于一些 UNIX 系统上,主要是 IBM 的系统,并在 2020 年添加到了 POSIX 规范中。但今天它并不被广泛支持,最重要的是 Linux 不支持它。有人在 2011 年提交了一个补丁,将其添加到 Linux 中,但似乎对此没有太多兴趣,另一个人在 2020 年又尝试了一次,但遇到了一些强烈的反对,这很遗憾,因为它会非常有用。

相反,大多数想要实现分支安全的代码所做的是,它尝试通过持续检查当前进程 ID 来检测是否发生了分支:

def query
  if Process.pid != @old_pid
    @connection.close
    @connection = nil
    @old_pid = Process.pid
  end

  @connection ||= connect
  @connection.query
end

或者依赖某些 at_fork 回调,在 C 语言中通常是指 pthread_atfork ,自从 Ruby 3.1 以来,你可以封装 Process._fork (注意 _ ):

module MyLibraryAtFork
  def _fork
    pid = super
    if pid == 0
      # in child
    else
      # in parent
      MyLibrary.close_all_ios
    end
    pid
  end
end
Process.singleton_class.prepend(MyLibraryAtFork)

由于 fork(2) 在 Ruby 中非常普遍,许多处理套接字的流行库,如 Active Recordredis gem,都尽力透明地处理这个问题,所以你不必担心。因此,在大多数 Ruby 程序中,它只是正常工作。

但是,对于本地语言来说,这可能会相当繁琐,这也是许多人绝对讨厌 fork(2) 的原因之一。任何使用文件或套接字的代码在调用 fork(2) 之后可能会完全损坏,除非特别关注了 fork 安全性,而这很少是情况。

一些您的线程可能会死亡

回到我们的echo服务器,你可能想知道为什么在这里使用 fork(2) 而不是线程。再次强调,我当时并不在那里,但我的理解是线程在后来的某个时候才成为了一件事(八十年代末?),而且即使它们存在了,也需要相当长的时间才能标准化和解决,因此才能跨平台使用。

也存在这样的观点,使用 fork(2) 进行多进程处理更容易理解。每个进程都有自己的内存空间,因此你不必过多担心竞态条件和其他线程陷阱,所以我明白为什么即使线程成为了一种选择,有些人可能还是更喜欢坚持使用 fork(2)

但是,由于线程是在 fork(2) 之后很久才被创造的,因此负责实现和标准化它们的人遇到了一些麻烦,没有找到让它们两者都能良好协作的方法。

这里 POSIX 标准 fork 条目关于该内容的说明是:

一个进程应使用单个线程创建。如果一个多线程进程调用 fork(),新进程应包含调用线程的副本及其整个地址空间,可能包括互斥锁和其他资源的状态。因此,为了避免错误,子进程只能在调用 exec 函数之前执行异步信号安全的操作。

换句话说,标准承认经典的 fork + exec 模式可以在多线程进程中实现,但对于不带着 execfork 使用,标准则显得有些置身事外。他们建议仅使用异步信号安全的操作,而这实际上只是很小一部分功能。所以,根据标准,如果你在创建了一些线程之后调用 fork(2),且并不打算立即调用 exec ,那么这里就充满了潜在的危险

原因在于,只有调用 fork(2) 的线程在子进程中保持存活,其他线程虽然存在,但已经死亡。如果另一个线程曾经锁定了一个互斥锁(mutex)或其他类似的资源,那么这个锁将永远保持锁定状态,如果新线程尝试获取它的话,这可能会导致死锁。

该标准还包括一个关于为什么是这样的原因说明部分,这部分内容有点长但很有趣:

在多线程世界中使 fork()工作通常存在的问题是如何处理所有线程。有两种选择。一种是将所有线程复制到新进程中。这导致程序员或实现必须处理那些在系统调用上挂起的线程,或者那些可能即将执行不应该在新进程中执行的系统调用的线程。另一种选择是只复制调用 fork()的线程。这造成了一个困难,即进程本地资源的状态通常保存在进程内存中。如果一个不调用 fork()的线程持有一个资源,那么在子进程中这个资源永远不会被释放,因为负责释放资源的线程在子进程中不存在。

当程序员编写多线程程序时, […] fork() 函数仅用于运行新程序,而在调用 fork() 和调用 exec 函数之间调用需要某些资源的函数的效果是未定义的。

将 forkall()函数加入标准中被考虑过并拒绝了。

所以他们确实考虑了拥有另一个版本的 fork(2) ,称为 forkall() ,这个版本也会复制其他线程,但他们无法想出一个清晰的语义(semantic)来解释在某些情况下会发生什么。

相反,他们为用户提供了一种方法,在 fork 附近调用回调以恢复状态,例如,重新初始化互斥锁。然而,如果你去看那个回调手册页 pthread_atfork(3) ,你可以读到:

pthread_atfork()的最初意图是允许子进程恢复到一个一致的状态。 […] 实际上,这项任务通常过于困难,难以实现。

所以尽管 pthread_atfork 仍然存在并且可以使用,但标准承认正确使用它是非常困难的。

这就是为什么许多系统程序员会告诉你永远不要将 fork(2) 与多线程程序混合使用,或者至少在创建线程后永远不要调用 fork(2) ,因为那时,一切都不确定了。因此,你多少必须选择你的阵营,看来线程明显赢了。

但这是针对 C 或 C++ 程序员的。

在今天的 Ruby 程序员的情况下,使用 fork(2) 而不是线程的原因是,这是在 MRI 上获得真正并行性的唯一方式(是的,从某种程度上来说也有 Ractors,但这将是下一篇帖子的主题) ,MRI 是 Ruby 的默认且最常用的实现。由于臭名昭著的 GVL,Ruby 线程只允许并行化 IO 操作,不能并行化 Ruby 代码执行,因此几乎所有的 Ruby 应用服务器都以某种方式集成了 fork(2) ,以便它们可以利用超过单个 CPU 核心。

幸运的是,Ruby 缓解了将线程与 fork(2) 混合使用的一些陷阱。例如,由于它们的实现方式,Ruby 互斥锁在所有者死亡时会自动释放。在伪 Ruby 代码中,它们看起来像这样:

class Mutex
  def lock
    if @owner == Fiber.current
      raise ThreadError, "deadlock; recursive locking"
    end

    while @owner&.alive?
      sleep(1)
    end

    @owner = Fiber.current
  end
end

当然,在现实中它们并不是在循环中睡眠以等待,它们使用一种更高效的方式来阻塞,但这只是为了给你一个大致的概念。重要的一点是,Ruby 互斥锁会保留对获取锁的 纤维(因此是线程)的引用,并在其死亡时自动忽略它。因此,在 fork 之后,所有由后台线程持有的互斥锁会立即释放,这避免了大多数死锁场景。

当然,这并不完美,如果一个线程在持有互斥锁时死亡,它很可能留下了由互斥锁保护的资源处于不一致的状态,在实践中我从未遇到过这样的情况,当然,这可能是因为全局解释器锁(GVL)的存在在一定程度上减少了对互斥锁的需求。

现在,Ruby 线程并非完全不受这些陷阱的影响,因为归根结底在 MRI 上,Ruby 线程是由本地线程支持的,所以如果另一个线程释放了 GVL 并调用了一个锁定互斥锁的 C API,你最终可能会遇到一个棘手的死锁问题。

尽管我从未得到确凿的证据,但我怀疑这对一些 Ruby 用户来说正在发生,因为据我了解,Ruby 用来解析主机名的 glibc 的 getaddrinfo(3) 确实使用了全局互斥锁,而 Ruby 在释放 GVL 的情况下调用它,允许并发发生 fork。

为了防止这种情况,我在 MRI 中增加了另一个锁,以防止在进行 getaddrinfo(3) 调用时发生 Process.fork 。这远非完美,但考虑到 Ruby 多么依赖 Process.fork ,这似乎是一个明智的做法。

依赖 fork 的 Ruby 程序在 macOS 上崩溃并不罕见,因为许多 macOS 系统 API 会隐式地创建线程或锁定互斥锁,而 macOS 选择在发生这种情况时一致性地崩溃。

所以即使使用纯 Ruby 代码,你偶尔也会遇到 fork(2) 的陷阱,你不能随意使用它。

结论

所以回答标题中的问题, fork(2) 被讨厌的原因是它组合性不好,特别是在原生代码中。如果你想使用它,你必须非常小心你正在编写和链接的代码。每当你使用一个库时,你必须确保它不会生成一些线程,或者持有文件描述符,并且在 fork(2) 和线程之间选择时,大多数系统程序员会选择线程。它们有自己的陷阱,但它们组合性更好,而且很可能你正在调用的 API 在后台使用线程,所以这个选择在某种程度上已经为你做好了。

但 Ruby 代码的情况远没有这么糟糕,因为它使得编写安全的代码变得更加容易,而且 Ruby 的理念使得像 Active Record 这样的库会为你处理这些复杂的细节。所以问题主要出现在你想要绑定到一些会生成线程的本地库时,比如 grpc 或 libvips ,因为它们通常不期望 fork(2) 会发生,并且通常不接受它作为一个约束。

尤其是因为 fork 大多在应用程序初始化结束时使用,即使技术上不是 fork 安全的库也会工作,因为它们通常在第一次请求时才懒洋洋地初始化它们的线程和文件描述符。

无论如何,即使你仍然认为 fork(2) 是邪恶的,但在 Ruby 提供另一个可用的原语来实现真正的并行性(这应该是下一篇文章的主题)之前,它将仍然是一个必要的邪恶。

所以,你想移除 GVL?

2025年2月8日 13:05

我想写一篇关于 Pitchfork 的文章,解释它的起源、为什么它会是这个样子,以及我对其未来的看法。但在达到这一点之前,我认为我需要分享我对一些事情的思维模型,在这个例子中,是 Ruby 的 GVL。

长期以来,人们常说 Rails 应用程序主要是 I/O 密集型,因此 Ruby 的 GVL (全局解释器锁)并不是什么大问题,这也影响了 Ruby 基础设施中一些基础组件的设计,如 Puma 和 Sidekiq。正如我在之前的文章中解释的那样,我认为对于大多数 Rails 应用程序来说,这并不完全正确。不管怎样,GVL 的存在仍然要求这些线程化系统使用 fork(2) 才能充分利用服务器的所有核心:每个核心一个进程。为了避免所有这些问题,有些人呼吁简单地移除 GVL。

但这真的这么简单吗?

GVL 和线程安全

如果你阅读有关 GVL 的帖子,你可能听说过它不是为了保护你的代码免受竞态条件的影响,而是为了保护 Ruby 虚拟机免受你的代码影响。换句话说,无论是否有 GVL,你的代码都可能受到竞态条件的影响,这是绝对正确的。

但这并不意味着 GVL 不是您应用程序中 Ruby 代码线程安全的重要组件。 让我们用一个简单的代码示例来说明:

译者注:这里表达很英语,比较绕口。中文的意思就是想表达:GVL 其实也会影响到你 Ruby 代码的线程安全。下面举例说明。

QUOTED_COLUMN_NAMES = {}

def quote_column_name(name)
  QUOTED_COLUMN_NAMES[name] ||= quote(name)
end

您说这段代码是线程安全的吗?还是不是?

嗯,如果你回答“它是线程安全的”,你并不完全正确。但如果你回答“它不是线程安全的”,你也不完全正确。

实际答案是:“视情况而定”。

首先,这取决于你对线程安全的定义有多严格,然后取决于那个 quote 方法是否是幂等的,最后还取决于你使用的 Ruby 解释器的实现。

让我解释一下。

首先, ||= 是一种语法糖,它隐藏了这段代码实际工作方式的一些细节,所以让我们去掉它的语法糖:

QUOTED_COLUMN_NAMES = {}

def quote_column_name(name)
  quoted = QUOTED_COLUMN_NAMES[name]

  # Ruby 可以在这里切换线程

  if quoted
    quoted
  else
    QUOTED_COLUMN_NAMES[name] = quote(name)
  end
end

在这个形式下,更容易看出 ||= 并不是一个单一的操作,而是多个操作。因此,即使在 MRI(即 CRuby 解释器)上,存在全局解释器锁(GVL),从技术上来说,Ruby在计算 quoted = ... 之后,也有可能抢占一个线程,并恢复另一个线程,而这个线程可能会带着相同的参数进入同一个方法。

换句话说,即使有 GVL,此代码也受竞态条件影响。更准确地说,它受“检查-执行(check-then-act)” 竞态条件影响。

译者注:“Check-then-act”是一种常见的操作模式,指的是先检查某个条件,然后根据检查结果执行相应操作。然而,这种模式在多线程环境下容易引发竞态条件(Race Condition),因为检查和执行之间存在时间间隔,在此期间其他线程可能改变相关状态,导致基于过时的检查结果执行操作。 作者这里就想表达这个经典的情况。

如果它存在竞态条件,你可以逻辑上推断出它不是线程安全的。但在这里,情况又有所不同。如果 quote(name) 是幂等的,技术上确实存在竞态条件,但它又没有实际的负面影响。quote(name) 可能会被执行两次而不是一次,其中一个结果会被丢弃,谁会在乎呢?这就是为什么在我看来,上述代码实际上仍然是线程安全的,不管怎样。

译者注:“幂等”(Idempotent)是一个数学和计算机科学中的概念,指的是一个操作或函数在多次执行后,其效果与执行一次相同。换句话说,无论执行多少次,结果都不会改变。幂等性在很多领域都有重要的应用,尤其是在分布式系统、数据库操作和网络协议中。

我们可以通过使用几个线程来实验验证这一点:

QUOTED_COLUMN_NAMES = 20.times.to_h { |i| [i, i] }

def quote_column_name(name)
  QUOTED_COLUMN_NAMES[name] ||= "`#{name.to_s.gsub('`', '``')}`".freeze
end

threads = 4.times.map do
  Thread.new do
    10_000.times do
      if quote_column_name("foo") != "`foo`"
        raise "There was a bug"
      end
      QUOTED_COLUMN_NAMES.delete("foo")
    end
  end
end

threads.each(&:join)

如果您使用 MRI 运行此脚本,它将正常运行,不会崩溃,并且 quote_column_name 将始终返回您预期的结果。

然而,如果您尝试使用 TruffleRuby 或 JRuby 运行它,它们是 Ruby 的替代实现,没有 GVL,您将得到大约 300 行错误

$ ruby -v /tmp/quoted.rb
truffleruby 24.1.2, like ruby 3.2.4, Oracle GraalVM Native [arm64-darwin20]
java.lang.RuntimeException: Ruby Thread id=51 from /tmp/quoted.rb:20 terminated with internal error:
    at org.truffleruby.core.thread.ThreadManager.printInternalError(ThreadManager.java:316)
    ... 20 more
Caused by: java.lang.NullPointerException
    at org.truffleruby.core.hash.library.PackedHashStoreLibrary.getHashed(PackedHashStoreLibrary.java:78)
    ... 120 more
java.lang.RuntimeException: Ruby Thread id=52 from /tmp/quoted.rb:20 terminated with internal error:
    at org.truffleruby.core.thread.ThreadManager.printInternalError(ThreadManager.java:316)
    ... 20 more
... etc

错误并不总是完全相同,有时似乎比其他时候更严重。但总的来说,它会在 TruffleRuby 或 JRuby 解释器内部深处崩溃,因为对同一哈希的并发访问导致它们遇到 NullPointerException

因此,我们可以说在 Ruby 的参考实现中这段代码是线程安全的,但在 Ruby 的所有实现中并不都是线程安全的。

该方式之所以如此,是因为在 MRI 中,线程调度器只能在执行纯 Ruby 代码时切换运行中的线程。每次调用实现于 C 的内置方法时,你都会隐式地受到 GVL 的保护。因此,所有实现于 C 的方法本质上都是“原子的”,除非它们明确释放 GVL。但一般来说,只有 IO 方法会释放它。

这就是为什么,这段从 Active Record 摘取的代码,没有使用 Hash 但使用了 Concurrent::Map

在 MRI 中,Concurrent::Map 几乎只是 Hash 的一个别名,但在 JRuby 和 TruffleRuby 中,它被定义为带有互斥锁的散列表。官方 Rails 不支持 TruffleRuby 或 JRuby,但在实际生产中,我们倾向于通过这种小改动来完成支持。

直接移除不就好了么

这就是为什么会有“移除 GVL”和“真的移除 GVL”。

简单的方法可以像 TruffleRuby 和 JRuby 那样:什么也不做,或者说是几乎什么也不做。

由于TruffleRuby、JRuby 实现是基于 Java 虚拟机(JVM)的,而 JVM 是内存安全的,因此它们将这种情况下“失败但不会直接崩溃”的艰巨任务委托给了 JVM 运行时。鉴于 MRI 是用 C 语言实现的,而 C 语言以“不支持内存安全”而闻名,如果仅仅移除 GVL,当你的代码触发这种竞态条件时,虚拟机可能会遇到段错误(segmentation fault)或者更糟糕的情况,因此事情并没有那么简单。

Ruby 需要在每个可能发生竞态条件的对象上实现类似于 JVM 的做法,为每个对象设置某种原子计数器。每次访问对象时,你都会增加它并检查它是否设置为 1 ,以确保没有其他人正在使用它。

这本身是一项相当具有挑战性的任务,因为它意味着要检查 C 语言中实现的所有方法(包括 Ruby 本身以及流行的 C 扩展),以插入所有这些原子递增和递减操作。

它还需要在大多数 Ruby 对象中为那个新计数器额外占用一些空间,可能是 4 或 8 个字节,因为原子操作在小整数类型上不容易完成。除非当然有一些我不知情的巧妙技巧。

这也会导致虚拟机的速度变慢,因为所有这些原子递增和递减很可能会有明显的开销,因为原子操作意味着 CPU 必须确保所有核心同时看到这个操作,所以它实际上锁定了 CPU 缓存的那部分。我不会尝试猜测这种开销在实践中会有多少,但肯定不是免费的。

然后结果就是,很多原本是线程安全的纯 Ruby 代码,将不再具备这种特性。因此,除了 ruby-core 需要做的工作之外,Ruby 用户可能还需要在他们的代码、gem 等中调试大量线程安全问题。

因此,尽管 JRuby 和 TruffleRuby 团队努力使其与 MRI 尽可能兼容,但由于缺少 GVL 这一特性,大多数非平凡代码库在它们之上运行前可能至少需要进行一些调试。这并不一定需要大量努力,这取决于情况,但比您平均每年的 Ruby 升级要麻烦得多。

移除GVL 的替代品方案

但是,这并不是移除 GVL 的唯一方法,另一种常见的设想是用无数的小锁来替换一个全局锁,每个可变对象一个锁。

关于需要完成的工作,它与之前的方法相当相似,你需要遍历所有 C 代码,并在每次接触可变对象时显式插入锁定和解锁语句。这还需要在每个对象上占用一些空间,可能比仅仅一个计数器要多一些。

采用这种方法,C 扩展可能仍需要一些工作,但纯 Ruby 代码将保持完全兼容。

如果您听说过最近半途而废的尝试移除 Python 的 GIL(相当于 Python 版本的 GVL),那么他们就是用的这种方法。那么,让我们看看他们做了哪些改动,从他们定义在 object.h 的基础对象布局开始

它有很多仪式性代码(Ceremonial Code),所以这里有一个简化版本:

译者注:“仪式代码”(Ceremonial Code)是指在编程过程中,为了满足某些框架、语言特性或规范要求而必须编写的一些额外代码,这些代码本身对核心功能的实现并没有直接帮助,但却是必要的步骤。

/* Nothing is actually declared to be a PyObject, but every pointer to
 * a Python object can be cast to a PyObject*.  This is inheritance built by hand.
 */
#ifndef Py_GIL_DISABLED
struct _object {
    Py_ssize_t ob_refcnt
    PyTypeObject *ob_type;
};
#else
// Objects that are not owned by any thread use a thread id (tid) of zero.
// This includes both immortal objects and objects whose reference count
// fields have been merged.
#define _Py_UNOWNED_TID             0

struct _object {
    // ob_tid stores the thread id (or zero). It is also used by the GC and the
    // trashcan mechanism as a linked list pointer and by the GC to store the
    // computed "gc_refs" refcount.
    uintptr_t ob_tid;
    uint16_t ob_flags;
    PyMutex ob_mutex;           // per-object lock
    uint8_t ob_gc_bits;         // gc-related state
    uint32_t ob_ref_local;      // local reference count
    Py_ssize_t ob_ref_shared;   // shared (atomic) reference count
    PyTypeObject *ob_type;
};
#endif

那里有相当多的内容,让我来概括下。简单起见,我的整个解释都将假设 64 位架构。

也请注意,虽然我曾经是 Pythonista ,那是在 15 年前,而现在我只是从远处观察 Python 的发展。总之,我会尽力准确描述他们正在做的事情,但完全有可能我会有些地方描述错误。

译者注:Pythonista 是指那些对 Python 编程语言非常热爱和精通的人,通常是对代码质量和编程风格有较高追求的开发者。

无论如何,当 GIL(Python 的全局解释器锁)没有被编译禁用的时候,每个 Python 对象都以 16B 开头,第一个 8B 称为 ob_refcnt 用于引用计数,正如其名,但实际上只使用 4B 作为计数器,其他 4B 用作位图来设置对象上的标志,就像在 Ruby 中一样。然后剩余的 8B 只是一个指向对象类的指针。

与比较,Ruby 的对象头称为 struct RBasic 也是 16B 。同样,它有一个指向类的指针,另一个 8B 用作存储许多不同的大位图(big bitmap)。

然而,当在编译期间禁用 GIL 时,对象头现在是 32B ,大小加倍。它以 8B ob_tid 开头,用于线程 ID,存储哪个线程拥有该特定对象。然后 ob_flags 被显式布局,但已缩减到 2B ,为 1B ob_mutex 腾出空间,并为一些我不太了解的 GC 状态腾出另一个 1B

4B ob_refcnt 字段仍然存在,但这次命名为 ob_ref_local ,并且还有一个 8B ob_ref_shared ,最后是对象类的指针。

仅通过对象布局的改变,你就能感受到额外的复杂性,以及内存开销。每个对象额外 16 个字节不是微不足道的。

现在,正如你可能从 refcnt(ref count) 字段中猜到的,Python 的内存主要通过引用计数来管理。它们还有一个标记和清除收集器,但它只是为了处理循环引用。在这方面,它与 Ruby 相当不同,但看看他们为了使这个线程安全而必须做的事情仍然很有趣。

让我们看看在 refcount.h 中定义的 Py_INCREF 。在这里,它充满了针对各种架构的 ifdef ,所以这里有一个简化版本,只包含当 GIL 激活时执行的代码,并移除了一些调试代码:


#define _Py_IMMORTAL_MINIMUM_REFCNT ((Py_ssize_t)(1L << 30))

static inline Py_ALWAYS_INLINE int _Py_IsImmortal(PyObject *op)
{
    return op->ob_refcnt >= _Py_IMMORTAL_MINIMUM_REFCNT;
}

static inline Py_ALWAYS_INLINE void Py_INCREF(PyObject *op)
{
    if (_Py_IsImmortal(op)) {
        return;
    }
    op->ob_refcnt++;
}

它非常简单,即使你不熟悉 C 语言,也应该能够读懂它。但基本上,它会检查引用计数是否设置为标记永生对象的魔法值,如果不是永生的,它就简单地执行一个常规的、非原子的、因此非常便宜的计数器递增。

关于“永生对象”(Immortal Objects)的补充说明,这是一个由 Instagram 工程师引入的非常酷的概念,我也一直想将其引入到 Ruby 中。如果你对类似“写时复制”(Copy-on-Write)和内存节省这类话题感兴趣,那么它绝对值得一读。

现在让我们看看移除 GIL 后的相同 Py_INCREF 函数:

#define _Py_IMMORTAL_REFCNT_LOCAL UINT32_MAX
# define _Py_REF_SHARED_SHIFT        2

static inline Py_ALWAYS_INLINE int _Py_IsImmortal(PyObject *op)
{
    return (_Py_atomic_load_uint32_relaxed(&op->ob_ref_local) ==
            _Py_IMMORTAL_REFCNT_LOCAL);
}

static inline Py_ALWAYS_INLINE int
_Py_IsOwnedByCurrentThread(PyObject *ob)
{
    return ob->ob_tid == _Py_ThreadId();
}

static inline Py_ALWAYS_INLINE void Py_INCREF(PyObject *op)
{
    uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local);
    uint32_t new_local = local + 1;
    if (new_local == 0) {
        // local is equal to _Py_IMMORTAL_REFCNT_LOCAL: do nothing
        return;
    }
    if (_Py_IsOwnedByCurrentThread(op)) {
        _Py_atomic_store_uint32_relaxed(&op->ob_ref_local, new_local);
    }
    else {
        _Py_atomic_add_ssize(&op->ob_ref_shared, (1 << _Py_REF_SHARED_SHIFT));
    }
}

这是现在更加复杂。首先,需要原子地加载 ob_ref_local ,正如之前提到的,这比正常加载要昂贵,因为它需要 CPU 缓存同步。然后,我们仍然有对不朽对象的检查,没有新内容。

有趣的部分是最后的 if ,因为有两种不同的情况,一种是对象由当前线程拥有,另一种则不是。因此,第一步是比较 ob_tid_Py_ThreadId() 。这个函数太大,无法在这里包含,但你可以检查 object.h 中的实现,在大多数平台上,这基本上是免费的,因为线程 ID 总是存储在 CPU 寄存器中。

当对象由当前线程拥有时,Python 可以通过先进行非原子性增加后进行原子性存储来避免问题。而在相反的情况下,整个增加操作必须原子性,这要昂贵得多,因为它涉及到比较和交换操作。这意味着在发生竞态条件的情况下,CPU 将重试增加操作,直到在没有竞态条件的情况下完成。

用 Ruby 伪代码描述,它可能看起来像这样:

def atomic_compare_and_swap(was, now)
  # 假设这个方法是一个 原子性 CPU 操作
  if @memory == was
    @memory = now
    return true
  else
    return false
  end
end

def atomic_increment(add)
  loop do
    value = atomic_load(@memory)
    break if atomic_compare_and_swap(value + add, value)
  end
end

因此,您可以看到,曾经是一个非常平凡的操作,即一个主要的 Python 热点,变成了一个明显更复杂的过程。Ruby 不使用引用计数,所以如果尝试移除 GVL,这个特定的情况不会立即翻译成 Ruby,但 Ruby 仍然有一系列非常频繁调用的类似例程,会受到类似的影响。

例如,因为 Ruby 的垃圾回收是代际和增量式的,当两个对象之间创建新的引用时,比如从 A 到 B ,Ruby 可能需要标记 A 为需要重新扫描,这是通过在位图中翻转一个位来完成的。这是需要使用原子操作进行更改的一个例子。

但我们还没有谈到实际的锁定。当我第一次听说 Python 试图移除它们的 GIL 时,我本以为他们会利用现有的引用计数 API 来将锁定放入其中,但显然,他们并没有这样做。我不确定为什么,但我猜因为语义并不完全匹配。

相反,他们必须做我之前提到的事情,即检查 C 中实现的所有方法,以添加显式的加锁和解锁调用。为了说明,我们可以看看 list.clear() 方法,它是 Array#clear 的 Python 等价方法。

在移除 GIL 的努力之前,它看起来是这样的:

int
PyList_Clear(PyObject *self)
{
    if (!PyList_Check(self)) {
        PyErr_BadInternalCall();
        return -1;
    }
    list_clear((PyListObject*)self);
    return 0;
}

它看起来比实际要简单,因为大部分复杂性都在 list_clear 例程中,但无论如何,它相当直接。

项目开始一段时间后,Python 开发者注意到他们忘记给 list.clear 和其他几个方法添加锁,因此他们进行了修改:

int
PyList_Clear(PyObject *self)
{
    if (!PyList_Check(self)) {
        PyErr_BadInternalCall();
        return -1;
    }
    Py_BEGIN_CRITICAL_SECTION(self);
    list_clear((PyListObject*)self);
    Py_END_CRITICAL_SECTION();
    return 0;
}

不太糟糕,他们设法将其全部封装在两个宏中,当 Python 启用 GIL 时,这些宏只是空操作。

我不会解释 Py_BEGIN_CRITICAL_SECTION 中发生的一切,有些东西我无论如何也理解不了,但简而言之,它最终会进入 _PyCriticalSection_BeginMutex ,其中有一个快速路径和一个慢速路径:

static inline void
_PyCriticalSection_BeginMutex(PyCriticalSection *c, PyMutex *m)
{
    if (PyMutex_LockFast(m)) {
        PyThreadState *tstate = _PyThreadState_GET();
        c->_cs_mutex = m;
        c->_cs_prev = tstate->critical_section;
        tstate->critical_section = (uintptr_t)c;
    }
    else {
        _PyCriticalSection_BeginSlow(c, m);
    }
}

快速路径所做的,是假设对象的 ob_mutex 字段设置为 0 ,并尝试通过原子比较和交换将其设置为 1 :

//_Py_UNLOCKED is defined as 0 and _Py_LOCKED as 1 in Include/cpython/lock.h
static inline int
PyMutex_LockFast(PyMutex *m)
{
    uint8_t expected = _Py_UNLOCKED;
    uint8_t *lock_bits = &m->_bits;
    return _Py_atomic_compare_exchange_uint8(lock_bits, &expected, _Py_LOCKED);
}

如果那样可以工作,它知道物体已被解锁,因此只需进行一点账目管理即可。

如果这种方法不起作用,那么它就会进入慢速路径,而在这里情况开始变得相当复杂。但为了快速描述一下,它首先会使用一个自旋锁(spin-lock),并且进行40次迭代。所以,在某种程度上,它会连续不断地执行40次比较和交换逻辑,寄希望于最终能够成功。如果这仍然不起作用,它就会将线程“挂起”(park),并等待一个信号来恢复运行。如果你对了解更多感兴趣,可以查看 Python/lock.c 中的_PyMutex_LockTimed 函数,并从那里跟踪代码。然而,对于我们的当前话题来说,互斥锁代码本身并没有那么有趣,因为假设大多数对象只被单个线程访问,所以快速路径才是最重要的。

但除了这条快速路径的成本之外,如何将锁定和解锁语句集成到现有代码库中也很重要。如果你忘记了一个 lock() ,可能会导致虚拟机崩溃,而如果你忘记了一个 unlock() ,可能会导致虚拟机死锁,这可以说是更糟糕的情况。

所以,让我们回到那个 list.clear() 例子:

int
PyList_Clear(PyObject *self)
{
    if (!PyList_Check(self)) {
        PyErr_BadInternalCall();
        return -1;
    }
    Py_BEGIN_CRITICAL_SECTION(self);
    list_clear((PyListObject*)self);
    Py_END_CRITICAL_SECTION();
    return 0;
}

您可能已经注意到 Python 是如何进行错误检查的。当发现一个不良的前置条件时,它通过一个 PyErr_* 函数生成一个异常,并返回 -1 。这是因为 list.clear() 总是返回 None (Python 的 nil ),所以其 C 实现的返回类型只是一个 int 。对于一个返回 Ruby 对象的函数,在错误条件下,它会返回一个 NULL 指针。

例如 list.__getitem__ ,它是 Python 中的 Array#fetch 的等价物,定义为:

PyObject *
PyList_GetItem(PyObject *op, Py_ssize_t i)
{
    if (!PyList_Check(op)) {
        PyErr_BadInternalCall();
        return NULL;
    }
    if (!valid_index(i, Py_SIZE(op))) {
        _Py_DECLARE_STR(list_err, "list index out of range");
        PyErr_SetObject(PyExc_IndexError, &_Py_STR(list_err));
        return NULL;
    }
    return ((PyListObject *)op) -> ob_item[i];
}

您可以在尝试使用越界索引访问 Python 列表时看到该错误:

>>> a = []
>>> a[12]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

您可以识别相同的 IndexError 和相同的 list index out of range 消息。

所以在这两种情况下,当用 C 实现的 Python 方法需要抛出异常时,它们会构建异常对象,将其存储在某些线程局部状态中,然后返回一个特定的值以让解释器知道发生了异常。当解释器注意到函数的返回值是这些特殊值之一时,它开始回溯堆栈。从某种意义上说,Python 异常是经典 if (error) { return error } 模式的语法糖。

现在让我们看看 Ruby 的 Array#fetch ,看看你是否注意到在处理越界情况时有什么不同:

static VALUE
rb_ary_fetch(int argc, VALUE *argv, VALUE ary)
{
    // snip...
    long idx = NUM2LONG(pos);
    if (idx < 0 || RARRAY_LEN(ary) <= idx) {
        if (block_given) return rb_yield(pos);
        if (argc == 1) {
            rb_raise(rb_eIndexError, "index %ld outside of...", /* snip... */);
        }
        return ifnone;
    }
    return RARRAY_AREF(ary, idx);
}

你注意到在 rb_raise 之后没有明确的 return 吗?

这是因为 Ruby 异常与 Python 异常非常不同,因为它们依赖于 setjmp(3)longjmp(3)

不深入细节,这两个函数本质上允许你为堆栈设置一个“保存点”并跳转回它。当它们被使用时,有点像非局部跳转 goto ,你直接跳转回父函数,所有中间函数都不会返回。

因此,Ruby 中的等效操作需要调用 setjmp ,并使用 EC_PUSH_TAG 宏将相关的检查点推送到执行上下文(本质上当前纤程),因此本质上每个核心方法现在都需要一个 rescue 子句,这并非免费。这是可行的,但可能比 Py_BEGIN_CRITICAL_SECTION 更昂贵。

我们继续

但我们过于专注于是否能够移除 GVL,以至于我们没有停下来思考是否应该这么做。

在 Python 的情况下,据我所知,推动移除 GIL 的努力主要来自机器学习社区,很大程度上是因为高效地喂养显卡需要相当高的并行度,而 fork(2) 并不适合。

然而,根据我的理解,Python Web 社区,如 Django 用户,似乎对 fork(2) 满意,尽管 Python 在 Copy-on-Write(写时复制)方面相对于 Ruby 处于重大劣势,因为正如我们之前所看到的,它的引用计数实现意味着大多数对象不断被写入,因此 CoW 页面很快就会失效。

另一方面,Ruby 的标记-清除 GC 对写时复制(Copy-On-Write)非常友好,因为几乎所有 GC 跟踪数据都不是存储在对象本身中,而是在外部位图中。因此,GVL 无锁线程的主要论点之一,即减少内存使用,在 Ruby 的情况下就不那么重要了。

鉴于 Ruby(无论好坏)主要用于 Web 应用,这至少可以部分解释为什么移除 GVL 的压力不像 Python 那样强烈。同样,Node.js 和 PHP 也没有自由线程(free threading),但据我所知,它们各自的社区对此并没有太多抱怨,除非我错过了什么。

如果 Ruby 要采用某种形式的自由线程,它可能需要在所有对象中添加某种形式的锁,并且会频繁地修改它,这可能会严重降低写时复制(Copy-on-Write)的效率。因此,这不会是一个纯粹的附加功能。

类似地,移除 Python GIL 的主要障碍之一一直是其对单线程性能的负面影响。当你处理易于并行化的算法时,即使单线程性能下降,通过使用更多的并行性,你可能仍然能够取得优势。但如果你使用 Python 的场景并行化困难,那么自由线程可能对你来说并不特别有吸引力。

历史上,Guido van Rossum 对移除 GIL 的立场是,只要它不影响单线程性能,他就欢迎这样做,这就是为什么它从未发生。现在,随着 Guido 不再是 Python 的仁慈独裁者,Python 指导委员会似乎愿意接受单线程性能的一些退步,但还不清楚这实际上会有多大。有一些数字在流传,但大多是来自合成基准测试等。我个人很想知道这种变化对 Web 应用的影响,在对此类变化发生在 Ruby 上感到热情之前。同时,需要注意的是,移除已被接受,但有一些前提条件,所以它还没有完成,他们可能在某个时候决定回头也是有可能的。

另一个需要考虑的问题是,对 Ruby 的性能影响可能比对 Python 更严重,因为需要额外开销的对象是可变对象,而与 Python 不同的是,Ruby 中的字符串也属于可变对象。想想一个普通的 Web 应用程序会执行多少次字符串操作。

另一方面,我想到的一个支持移除 GVL 的论点就是 YJIT。鉴于 YJIT 生成的本地代码及其关联的元数据仅限于进程范围,不再依赖 fork(2) 进行并行处理,仅通过共享所有这些内存,就能节省相当多的内存。然而,移除 GVL 也会让 YJIT 的工作变得更加困难,因此这也可能阻碍其进展。

另一个支持自由线程的论点是,派生的进程难以共享连接。因此,当您开始将 Rails 应用程序扩展到大量 CPU 核心时,您将比具有自由线程的堆栈拥有更多连接到您的数据存储,这可能会成为一个大瓶颈,尤其是在一些像 PostgreSQL 这样的具有昂贵连接的数据库中。目前,这主要通过使用外部连接池器来解决,如 PgBouncer 或 ProxySQL,我知道它们并不完美。这又是一个可能出错的新组件,但我认为这比自由线程要少很多麻烦。

最后,我想指出,GVL 并不是全部。如果目标是替换 fork(2) 为多线程,即使移除了 GVL,我们可能仍然不完全达到目标,因为 Ruby 的 GC 是“暂停世界(stop the world)”,所以随着单个进程中代码执行量的增加,因此分配也会更多,我们可能会发现它将成为新的竞争点。所以,我个人更愿意在希望移除 GVL 之前,先实现一个完全并发的 GC。

译者注:暂停世界(stop the world) :因为 GC(垃圾回收) 的时候会暂停所有程序的执行,进行对游离变量的盘点、回收,再恢复执行。所以使用 GC 语言可能会很慢、甚至无法预测的卡住。高性能的游戏领域会用 C、C++ 这种手动控制内存回收的语言,避免这种特点。

所以,保持现状?

在这个时候,有些人可能觉得我好像在试图洗脑人们,让他们认为 GVL 永远不会成为问题,但那并不是我的真实想法。

我绝对认为 GVL 目前在实际应用中造成了一些非常真实的问题,即竞争。但这与想要移除 GVL 是截然不同的,我相信情况可以通过其他方式显著改善。

如果您已经阅读了我关于如何在 Ruby 中正确测量 IO 时间的短文,您可能已经熟悉了 GVL 竞争问题,但让我在这里包含相同的测试脚本:

require "bundler/inline"

gemfile do
  gem "bigdecimal" # for trilogy
  gem "trilogy"
  gem "gvltools"
end

GVLTools::LocalTimer.enable

def measure_time
  realtime_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
  gvl_time_start = GVLTools::LocalTimer.monotonic_time
  yield

  realtime = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - realtime_start
  gvl_time = GVLTools::LocalTimer.monotonic_time - gvl_time_start
  gvl_time_ms = gvl_time / 1_000_000.0
  io_time = realtime - gvl_time_ms
  puts "io: #{io_time.round(1)}ms, gvl_wait: #{gvl_time_ms.round(2)}ms"
end

trilogy = Trilogy.new

# Measure a first time with just the main thread
measure_time do
  trilogy.query("SELECT 1")
end

def fibonacci( n )
  return  n  if ( 0..1 ).include? n
  ( fibonacci( n - 1 ) + fibonacci( n - 2 ) )
end

# Spawn 5 CPU-heavy threads
threads = 5.times.map do
  Thread.new do
    loop do
      fibonacci(25)
    end
  end
end

# Measure again with the background threads
measure_time do
  trilogy.query("SELECT 1")
end

如果您运行它,您应该会得到类似的结果:

realtime: 0.22ms, gvl_wait: 0.0ms, io: 0.2ms
realtime: 549.29ms, gvl_wait: 549.22ms, io: 0.1ms

本脚本演示了 GVL 竞争如何对应用程序的延迟造成破坏。即使您使用像 Unicorn 或 Pitchfork 这样的单线程服务器,这也并不意味着应用程序只使用单个线程。拥有各种后台线程来执行一些服务任务,如监控,是非常常见的。其中一个例子是 statsd-instrument gem。当您发出一个指标时,它会在内存中收集,然后一个后台线程负责批量序列化和发送这些指标。它应该主要是 IO 工作,因此不应该对主线程有太大影响,但在实践中,可能会发生这些类型的后台线程比您希望的更长时间地持有 GVL。

所以,尽管我的演示脚本非常极端,你绝对可以在生产环境中体验到一定程度的 GVL 竞争,无论你使用什么服务器。

但我认为尝试移除 GVL 并不一定是解决这个问题的最佳方法,因为这需要多年的泪水和汗水,才能获得点好处。

在 2006 年之前,多核 CPU 基本上不存在,然而,你仍然能够以相对顺畅的方式在电脑上多任务处理,比如在 Excel 中处理数字的同时在 Winamp 中播放音乐,而且这一切都不需要并行处理。

那是因为即使是 Windows 95 也有一个相当不错的线程调度器,但 Ruby 还没有。当 Ruby 中的线程准备好执行并需要等待 GVL 时,它会将其放入一个 FIFO 队列中,每当正在运行的线程释放 GVL,无论是由于进行了某些 I/O 操作还是因为运行了分配的 100 毫秒后,Ruby 的线程调度器就会弹出下一个线程。

没有任何优先级的概念。一个半不错的调度器应该能够注意到一个线程主要是 IO,打断当前线程来更快地调度 IO 密集型线程可能是值得的。

在尝试移除 GVL 之前,尝试实现一个合适的线程调度器是值得的。这个想法归功于 John Hawthorn

与此同时,Aaron Patterson(tenderlove)Ruby 3.4 中发布了一个更改,允许通过环境变量减少 100 毫秒的量子。这并不能解决所有问题,但可能已经在某些情况下有所帮助,所以这是一个开始。

译者注:量子(quantum)是 Ruby 解释器中的一个超时时间,默认100毫秒,Ruby 3.4 可以被轻松设置。解释器在执行线程的时候,如果超过了这个时间,就会回收 GVL,切换另一个线程执行。主要用来调度多个线程工作使用。当降低这个时间,可以更精细的切分正在执行的函数,加快多个线程排队轮转执行的速度,可以提高 IO 密集型应用的性能。

另一个约翰在我们的一次对话中分享的想法是,允许在 GVL 释放时进行更多的 CPU 操作。目前,大多数数据库客户端只在 IO 时真正释放 GVL,把它想象成这样:

def query(sql)
  response = nil
  request = build_network_packet(sql)

  release_gvl do
    socket.write(request)
    response = socket.read
  end

  parse_db_response(response)
end

对于返回大量数据的简单查询,很可能你在持有 GVL(全局解释器锁)的情况下构建 Ruby 对象所花费的时间,比在释放 GVL 的情况下等待数据库响应的时间要多得多。

这是因为非常非常少的 Ruby C API 可以使用 GVL 释放,特别是任何分配对象或可能抛出异常的内容都必须获取 GVL。

如果取消这一限制,使得你可以在释放 GVL 的情况下创建基本的 Ruby 对象(如字符串、数组和哈希表),那么很可能会让 GVL 释放的时间更长,并显著减少线程竞争。

结论

我本人并不真正支持取消 GVL,我认为这种权衡并不值得,至少目前还不值得,我也不认为它将像一些人想象的那样成为一个巨大的变革。

如果它对经典(主要是单线程)性能没有影响,我可能不会介意,但它几乎肯定会显著降低单线程性能,因此这感觉有点像“多得不如现得”的论点。

译者注:a bird in the hand is worth two in the bush(一鸟在手胜过双鸟在林)。这里翻译为:多得不如现得。到手才是真的,落袋为安的意思。

相反,我认为我们可以对 Ruby 进行一些更容易和更小的改动,这将能在更短的时间内以及更少的努力下改善情况,既对 Ruby 核心也对 Ruby 用户来说都是如此。

当然,这只是单一 Ruby 用户的观点,主要考虑的是我自己的使用场景,最终决定权在 Matz 手中,根据他认为社区想要和需要什么来决定。

目前,Matz 不想移除 GVL,而是接受了 Ractor 的提议。也许他的观点有一天会改变,我们拭目以待。

Ractor 我本想在这篇帖子中讨论的,但已经太长了,所以可能下次再说。

Ruby 的“线程竞争”就是 GVL 排队

2025年2月7日 14:48

最近 Jean Boussier 发布了很多精彩的帖子:

它们都是值得一读的!

长期以来,我一直误解了“线程竞争”这个词语。作为 GoodJob(👍)的作者和 Concurrent Ruby 的维护者,以及做了十多年的 Ruby 和 Rails 相关工作,这一点确实有点尴尬。但确实如此。

我已经阅读了很久关于线程竞争的内容。

通过这一切,我把线程竞争看作是竞争:一场斗争,一堆线程都在互相推搡着运行,乱糟糟地踩在彼此身上,这是一个低效、令人不悦且杂乱无章的混乱局面。但实际情况根本不是这样!

相反:当你有任意数量的线程在 Ruby 中时,每个线程都会有序地排队等待获取 Ruby GVL,然后它们会温和地持有 GVL,直到它们优雅地放弃它或者它被礼貌地从他们那里拿走,然后线程回到队列的末尾,在那里它们再次耐心地等待。

这是 Ruby 中“线程竞争”的含义:GVL 的有序排队。并不那么疯狂。

让我们更进一步

我是在研究 “是否应该降低 GoodJob 的线程优先级”(我确实降低了)时意识到这一点的。这个问题是在GitHub(我的日常工作场所)进行了一些探索之后出现的。在 GitHub,我们有一个用于维护的后台线程,如果这个后台线程执行时机恰好与 Web 服务器(Unicorn)响应 Web 请求的时间重合,就会偶尔导致我们无法达到某个Web请求的性能目标。

Ruby线程是操作系统线程。而操作系统线程是抢占式的,这意味着操作系统负责在活动线程之间切换CPU执行。但是,Ruby控制着它的全局虚拟机锁(GVL)。Ruby在线程执行方面扮演了重要角色,Ruby 通过选择将 GVL 交给哪个Ruby线程以及何时收回GVL来决定操作系统正在执行哪个线程。

(旁白:Ruby 3.3 引入了 M:N 线程,这解耦了 Ruby 线程与操作系统线程的映射,但在这里忽略这个细节。)

Ruby VM 内部发生的事情在《Ruby 黑客指南》中有非常好的 C语言级别的解释。但我会尽力在这里简要解释:

当线程到达队列的顶部并获得GVL时,该线程将开始运行其 Ruby 代码,直到它放弃 GVL。放弃 GVL 可能出于以下两个原因:

  1. 当线程从执行 Ruby 代码转向进行 IO 操作时,它会释放 GVL(通常情况下;如果 IO 库没有这样做,通常被认为是一个 bug)。当线程完成其 IO 操作后,线程会排到队列的末尾。
  2. 当线程执行时间超过线程 “量子(quantum)” 的长度时,Ruby VM 会收回 GVL,线程再次回到队列的末尾。Ruby 线程“量子”默认为 100ms(这可以通过 Thread#priority 配置,或者从 Ruby 3.4 开始直接通过环境变量配置)。

那个第二种情况相当有趣。当一个 Ruby 线程开始运行时,Ruby 虚拟机使用另一个后台线程(在虚拟机级别),该线程休眠 10 毫秒(“滴答(Tick)”),然后检查 Ruby 线程已经运行了多长时间。如果线程运行的时间超过了量子的长度,Ruby 虚拟机就会从活跃线程中收回 GVL(“抢占”),并将 GVL 交给在 GVL 队列中等待的下一个线程。之前正在执行的线程现在会排到队列的末尾。换句话说:

“线程量子(quantum) 决定了线程通过队列的速度,且不会比滴答(Tick) 更快。”

就是这样!这就是 Ruby 线程争用的情况。一切都井然有序,只是可能比预期或希望的要花费更长的时间。

有什么问题

多线程行为中令人畏惧的“尾部延迟(Tail Latency)”可能会发生,这与 “Ruby 线程量子”(Ruby Thread Quantum)有关。

比如:当你有一个时间非常短请求时,例如:

  • 一个可能需要 10 毫秒请求,比如向 Memcached/Redis 发起十个 1 毫秒的调用以获取一些缓存值,然后返回它们(I/O 密集型线程)

但是它相邻的运行线程是这样:

  • 一个需要 1000 毫秒的请求,大部分时间都花在字符串操作上,例如一个后台线程正在处理一堆复杂的哈希和数组,并将它们序列化成一个要发送到埋点服务器的数据。或者为 Turbo Broadcasts 渲染慢速/大型/复杂的视图(CPU 密集型线程)。

在这种情况下,CPU 密集型线程将非常贪婪地持有 GVL,它看起来会是这样:

  1. IO密集线程:启动 1 毫秒网络请求并释放 GVL
  2. CPU密集线程:在 GVL 被取回之前,在 CPU 上执行 100 毫秒的工作
  3. IO密集线程:再次获取 GVL 并启动下一个 1 毫秒网络请求并释放 GVL
  4. CPU密集线程:在 GVL 被取回之前,在 CPU 上执行 100 毫秒的工作
  5. 重复……再重复……
  6. 现在 1,000 毫秒后,理论上应该只花费 10 毫秒的 I/O 密集型线程终于完成了。这非常糟糕!

这是在这个只有两个线程的简单场景中最坏的情况。随着更多不同工作负载的线程,你可能会遇到更多的问题。Ivo Anjo 也对此进行了讨论。你可以通过降低整体线程量子来加快速度,或者通过降低CPU密集型线程的优先级(降低这个线程的量子)来实现。这将导致CPU密集型线程被更细致地切分,但由于最小时间片由时钟周期 Tick(10 毫秒)决定,所以对于上面这个 I/O 密集型线程来说,其等待时间理论上永远不会低于 100 毫秒,这比优化前快了 10 倍。

译者注

1. 考证 quantum 的存在

线程的 quantum 时间是 100ms

源码位置 thread.c#L119

// .....
static uint32_t thread_default_quantum_ms = 100;
// .....

2. 考证 Tick(10ms) 的存在

源码位置 thread_pthread.c#L2829

static int
timer_thread_set_timeout(rb_vm_t *vm)
{
#if 0
    return 10; // ms
#else
    int timeout = -1;

    ractor_sched_lock(vm, NULL);
    {
       // .......
            timeout = 10; // ms
       // .......
    }
    // .......
    return timeout;
#endif
}

MRuby Devkit 一个简单的脚手架,帮助你像 Go 一样把 Ruby 编译成可执行二进制

2024年6月28日 19:09

MRuby Devkit

MRuby Devkit 是一个开箱即用的脚手架。 基于 MRuby 将你的 Ruby 代码打包成 二进制可执行文件。

方便开发类似于 Golang 的二进制可执行文件。

—— 灵感来自于 Golang 可以编译为二进制可执行文件的迷人特性。

一、使用约定

前置运行环境

MacOS、Linux

  • GCC/Clang
  • Make
  • Git
  • Ruby3
    • Rake 安装 gem install rake

约定

1. src/main.rb 程序入口

程序入口不可修改。它是 runbuild 寻找的入口。

2. src/lib/*.rb 是多文件

lib 中适合存放拆分的多文件。

多文件中,如果存在依赖关系。需要特殊命名比如 01xxx, 02xxx …… 控制相对顺序。

多文件最终会被拼接成一个上下文送入编译。

3. mruby.conf.rb 是 mgem 配置文文件

可以引入 第三方 mgem

裁剪需要加入的 gem,控制编译选项。

注意:

  • 第三方标准库并不是每一个都可以被正确 build 比如 mgem-curses 无法 build,因为存在 BUG。
  • 要正确的配置编译选项,确保 mruby 产生。
  • 配置的 mgem 可以直接在上下文中使用,不需要 require

差异

  • MRuby 和 CRuby 标准库有差异,请关注官方的文档
  • 工作模式是:裁剪 mgem 、功能,最后编译的解释器 + mruby 代码 进行联合工作。 mruby 代码不需要 require 语句导入包。
  • MRuby 有可能工作在嵌入式环境中,以及可能没有文件系统的硬件中。所以编译成 二进制应用。
  • MRuby 和 CRuby 内核不同。 MRuby 实现精简高效,全部采用可跨平台的 C 语言,内存实现高效,精简,适用于嵌入式、跨平台。
  • MRuby 更像是 C 项目在开发,需要了解 C 语言以及构建的概念

二、开发

0. 编写程序

src 下编写 ruby 程序

1. 运行程序

模仿 golang 的 go run

rake run

2. 编译当前程序(默认使用当前计算机平台)

模仿 golang 的 go build

rake build

3.交叉编译的包

借助 Github Action 编译不同平台的可执行二进制文件。

  • 可以 fork 仓库在 Github Action 运行结果下可以看到构建产物。

Github Action 提供免费的 Runner

  • Windows
  • MacOS AMD64
  • MacOS ARM64
  • Ubuntu AMD64

如果你想获得 Linux aarch64 需要自建 Runner 所以你需要修改 .github/workflows/raspbian-aarch64.yml 使用自己的支持 aarch64 的 runner。

4. 内置 Rake 命令

rake -T 查看可用命令

➜  build git:(main) rake -T
rake build               # build program
rake build_merge         # merge program in build
rake build_to_c          # build to c code
rake cache_merge         # merge program in cache
rake clean               # clean
rake init_build          # init build dir
rake init_cache          # init develop cache dir
rake mruby:build         # build mruby
rake mruby:build_config  # replace mruby build config
rake mruby:custom_build  # custom config build mruby
rake mruby:download      # download mruby
rake mruby:init          # init
rake run                 # run program

TODO

  • 交叉编译
  • 多文件
  • run 命令
  • build 命令
  • 自动初始化

平台:

MacOS

  • AMD64 ✅
  • ARM64 ✅

Debian/Ubuntu/Mint Linux

  • AMD64 ✅
  • Aarch64 ✅

Ruby打包技术之旅

2024年5月29日 10:59

2025.11.15 追加

homebrew 打包 portable ruby 的思路

将所有依赖 全部静态化重构最后打包出静态 ruby,并且还可以安装 gem

https://github.com/spinel-coop/rv-ruby/releases


追加:

2025 迎来了新的方案:

https://github.com/tamatebako/tebako

通过各种 hack 完全可以把 ruby 打包成独立的二进制执行文件。


结论:

似乎找到了 2 个 Portable Ruby 实例

  • [Windows Ruby (Portable) 3.3.1.1 ](https://community.chocolatey.org/packages/ruby.portable)
  • [MacOS homebrew/portable-ruby ](https://github.com/Homebrew/homebrew-portable-ruby/pkgs/container/portable-ruby%2Fportable-ruby)

原文:

背景

大家好,我是 Mark24。

设想一下,如果你在用 Ruby 开发一个 GUI 应用,或者是 游戏。如何把产物可以送到你用户的手中。尽可能的轻松跑起来?

我目前感兴趣的是游戏应用。所以后面都是建立在游戏跑在终端的角度考虑。

虽然我们在讨论 Ruby ,但是对于所有动态脚本语言的思路是通用的。

解决打包动态语言的问题。最后一公里,如何送到用户手中。

思路一: 编译并静态链接,经典二进制包

1. 像静态语言一样,获得直接的二进制文件 ❌

比如 Go、Rust、Crystal 的构建产物。

结论:

Go、Rust、Crystal …… 他们依然是在有限条件下运行。只不过这种条件实际上特别宽泛,好像他们的产物可以在各种系统下运行。

实际上 MacOS、Linux、Windows 的底层都是不鼓励静态链接。并且一些关键的包,也不提供静态链接需要的库。

这是为了体积考虑,也是为了安全更新考虑。

这些能够相对来说把自己打成静态链接的语言,实际上都做了大量的工作,自己实现了底层需要的部分。

动态语言无法直接把代码打包成这样。 这条路是违背原理的。

2:极致的静态方向 ✅

这个思路是 MRuby。

MRuby 是一个轻量级的 Ruby 为嵌入式设计。它可以交叉编译成不同的架构。被设计的尽可能的少依赖,多拓展。

一定程度上,MRuby 就像是 Go。

可以用 MRuby 来构建应用、游戏。 MRuby 也有 SDL 的绑定

2.1 Dragon Ruby ✅

这里有篇演讲, Dragon Ruby 的游戏引擎设计者,如何使用 MRuby 来构建一个应用。

Dragon Ruby 从 IDE 到 游戏产物全部是静态二进制。

但是具体的原理不详。依然不知道 Dragon Ruby 是如何做到的。

2.2 Taylor ✅

Taylor 是个个人开源框架,试图挑战 Dragon Ruby。

Taylor 的思路也是经典思路,容器中构建一个可以被静态的环境,绕过系统(MacOS 不允许静态链接系统 lib)。

这些代码可能很难理解,在于他们究竟如何在发挥具体作用。

Taylor 正在重大重构中,但是目前的版本,是完全可以工作的!

思路二:解释器+项目代码 => 压缩包

这个思路需要一个 可以移动执行的 Ruby 解释器。

1. 静态编译 ruby 为 portable ruby ✅

如果拥有了 Portable Ruby,那么 软件包 = (Portable Ruby + 项目代码)。

这条路相对可行。

还是前面的问题,Ruby 没有像 Go 等实现了全部的底层依赖的静态库。所以 编译 != portable。

Portable 的重点就是,尽可能的不依赖。如果实在无法避开的依赖,比如 Linux 中的 glibc(系统底层),需要使用较低版本来编译。 因为 glibc 永远是高版本兼容低版本,所以这样尽可能的获得兼容性。

Crystal、Go …… 他们一样。也只能工作在有限的 glibc 中。

Crystal 给出了平台很好体现了这一点:Crystal Platform Support

用户不需要安装 Ruby,但是需要安装 Ruby 需要的底层库。来获得动态链接库。

这个思路获得了成功。

  • 1)让你本地安装 lib;或者直接安装 ruby(过程中就获得了需要的 lib)
  • 2)打包 portable ruby
  • 3)使用 Mac 的 App 壳应用

  • App 壳应用 Gosu

可以获得一个 Mac 的应用。

1.1 Portable rub + Portable libs 🤔 ✅

前面说了,如果可以创造出 静态的包。Ruby 也可以像 Go、Java 一样。这里参考这样一个项目,尝试在容器中模拟一个这样的环境。尽可能把所需的依赖全部集成起来,产出 portable ruby

不过这个产物我没怎么跑起来。但是这个是经典思路,是完全可行的。

app code + (Portable Ruby + lib) = software

思路三: 普通思路,前置安装器 ✅

用户安装 Ruby 运行游戏。由于前面无法实现彻底的静态打包,即使是安装依赖库,整个过程是差不多的。用户依然要安装。

如果这样避不开。推荐常见的处理办法 —— 前置的安装器(Installer)。解决环境依赖问题。

在 Windows 上 Ruby 是需要 安装包来安装。整个过程就像这样。

这一点,在 Windows 上也成功实现了:

  • Ruby2D 的 demo
  • Raylib-bindings 的 demo

构建过程和 Sample Project: ruby-windows-example

思路四: 切换可以打包的语言

1. 使用 静态语言 Crystal ✅ 🕘

Crystal 的语法和 Ruby 非常相似,也有 游戏库、GUI 的绑定。

可以做到类似的事情。这一点就像 C++

但是缺点是 Crystal 目前还在建设中。

Crystal 对 MacOS ARM、Windows 的支持还不足。

现在无法当作一个成熟方案。

2. 使用 JRuby(Java) ✅

Java 其实采用了类似的思路,自己实现了底层。所以 Java 自身可以打包成静态的二进制。

我们可以把打包工作建立在 Java 的基础上。

这个实践方向是 Glimmer

Glimmer DSL for SWT 能够在 JRuby 之上将 Ruby 应用程序打包到原生安装程序(如 Mac DMG/PKG/APP、Windows MSI/EXE 和 Linux RPM/DEB)中,使开发者能够给最终用户(非程序员)一个单一的文件来运行,以安装所有需要的内容,比如 JRuby(可以运行任何 Ruby 代码)、它的 JVM 依赖项,以及正在安装的应用程序:

Glimmer DSL for LibUI,它直接在 Ruby 上运行而不是 JRuby,也有一个关于打包 Ruby 应用程序的部分,你可能想要查看(它提到了 Windows 和 Mac 的打包解决方案):

以下是使用 Glimmer DSL for SWT 打包的应用程序示例,这些应用程序由最终用户安装,没有问题:

这些都是作者发来的例子。尝试跑了几个,没有成功。 还需要研究研究。

总结

如何把 Ruby 带到终端,其实一直不停的有人研究。项目生生死死。这里列举一些,供参考。

1)容器打包, 静态链接 portable ruby 思路:

2)临时文件系统思路:

3)JRuby 思路:

4)Portable Ruby 思路:

5)只打包应用脚本,指定系统 Ruby

  • platypus 只打包你的脚本,封装成 app,只适合简单脚本

6)静态语言

使用 Crystal , Ruby 语法的 Go like 语言开发应用

7)使用 Zig

这是一个问号,Zig 作为一个新语言可以作为 C 的环境,而且自己实现了所有的静态库。

不知道 Zig 作为 CRuby 的编译器会如何? 但是 Zig 目前依然在发展中。

8)使用容器

容器技术是任何语言的一个打包工具。

对于开发者友好,但是终端用户还是有门槛的。

不适合游戏应用。

9)Gem

如果都能接受用户总归要自己安装 Ruby 的设定。

把游戏、应用,封装成 gem,可以自动处理依赖、版本问题。

10)切换 Ruby 的实现: CRuby 无法实现静态打包

  • artichoke Rust 实现的 Ruby 。开在开发中。(暂不支持 gem)
  • natalie C++实现的 Ruby。开发中。可以 编译 纯 Ruby 脚本。(暂不支持 gem)

补充:

  • [Windows Ruby (Portable) 3.3.1.1 ](https://community.chocolatey.org/packages/ruby.portable)
  • [MacOS homebrew/portable-ruby ](https://github.com/Homebrew/homebrew-portable-ruby/pkgs/container/portable-ruby%2Fportable-ruby)

补充资料:

Ruby打包技术之旅

2024年5月29日 10:59

背景

大家好,我是 Mark24。

设想一下,如果你在用 Ruby 开发一个 GUI 应用,或者是 游戏。如何把产物可以送到你用户的手中。尽可能的轻松跑起来?

我目前感兴趣的是游戏应用。所以后面都是建立在游戏跑在终端的角度考虑。

虽然我们在讨论 Ruby ,但是对于所有动态脚本语言的思路是通用的。

解决打包动态语言的问题。最后一公里,如何送到用户手中。

思路一: 编译并静态链接,经典二进制包

1. 像静态语言一样,获得直接的二进制文件 ❌

比如 Go、Rust、Crystal 的构建产物。

结论:

Go、Rust、Crystal …… 他们依然是在有限条件下运行。只不过这种条件实际上特别宽泛,好像他们的产物可以在各种系统下运行。

实际上 MacOS、Linux、Windows 的底层都是不鼓励静态链接。并且一些关键的包,也不提供静态链接需要的库。

这是为了体积考虑,也是为了安全更新考虑。

这些能够相对来说把自己打成静态链接的语言,实际上都做了大量的工作,自己实现了底层需要的部分。

动态语言无法直接把代码打包成这样。 这条路是违背原理的。

2:极致的静态方向 ✅

这个思路是 MRuby。

MRuby 是一个轻量级的 Ruby 为嵌入式设计。它可以交叉编译成不同的架构。被设计的尽可能的少依赖,多拓展。

一定程度上,MRuby 就像是 Go。

可以用 MRuby 来构建应用、游戏。 MRuby 也有 SDL 的绑定

2.1 Dragon Ruby ✅

这里有篇演讲, Dragon Ruby 的游戏引擎设计者,如何使用 MRuby 来构建一个应用。

Dragon Ruby 从 IDE 到 游戏产物全部是静态二进制。

但是具体的原理不详。依然不知道 Dragon Ruby 是如何做到的。

2.2 Taylor ✅

Taylor 是个个人开源框架,试图挑战 Dragon Ruby。

Taylor 的思路也是经典思路,容器中构建一个可以被静态的环境,绕过系统(MacOS 不允许静态链接系统 lib)。

这些代码可能很难理解,在于他们究竟如何在发挥具体作用。

Taylor 正在重大重构中,但是目前的版本,是完全可以工作的!

思路二:解释器+项目代码 => 压缩包

这个思路需要一个 可以移动执行的 Ruby 解释器。

1. 静态编译 ruby 为 portable ruby ✅

如果拥有了 Portable Ruby,那么 软件包 = (Portable Ruby + 项目代码)。

这条路相对可行。

还是前面的问题,Ruby 没有像 Go 等实现了全部的底层依赖的静态库。所以 编译 != portable。

Portable 的重点就是,尽可能的不依赖。如果实在无法避开的依赖,比如 Linux 中的 glibc(系统底层),需要使用较低版本来编译。 因为 glibc 永远是高版本兼容低版本,所以这样尽可能的获得兼容性。

Crystal、Go …… 他们一样。也只能工作在有限的 glibc 中。

Crystal 给出了平台很好体现了这一点:Crystal Platform Support

用户不需要安装 Ruby,但是需要安装 Ruby 需要的底层库。来获得动态链接库。

这个思路获得了成功。

  • 1)让你本地安装 lib;或者直接安装 ruby(过程中就获得了需要的 lib)
  • 2)打包 portable ruby
  • 3)使用 Mac 的 App 壳应用

  • App 壳应用 Gosu

可以获得一个 Mac 的应用。

1.1 Portable rub + Portable libs 🤔 ✅

前面说了,如果可以创造出 静态的包。Ruby 也可以像 Go、Java 一样。这里参考这样一个项目,尝试在容器中模拟一个这样的环境。尽可能把所需的依赖全部集成起来,产出 portable ruby

不过这个产物我没怎么跑起来。但是这个是经典思路,是完全可行的。

app code + (Portable Ruby + lib) = software

思路三: 普通思路,前置安装器 ✅

用户安装 Ruby 运行游戏。由于前面无法实现彻底的静态打包,即使是安装依赖库,整个过程是差不多的。用户依然要安装。

如果这样避不开。推荐常见的处理办法 —— 前置的安装器(Installer)。解决环境依赖问题。

在 Windows 上 Ruby 是需要 安装包来安装。整个过程就像这样。

这一点,在 Windows 上也成功实现了:

  • Ruby2D 的 demo
  • Raylib-bindings 的 demo

构建过程和 Sample Project: ruby-windows-example

思路四: 切换可以打包的语言

1. 使用 静态语言 Crystal ✅ 🕘

Crystal 的语法和 Ruby 非常相似,也有 游戏库、GUI 的绑定。

可以做到类似的事情。这一点就像 C++

但是缺点是 Crystal 目前还在建设中。

Crystal 对 MacOS ARM、Windows 的支持还不足。

现在无法当作一个成熟方案。

2. 使用 JRuby(Java) ✅

Java 其实采用了类似的思路,自己实现了底层。所以 Java 自身可以打包成静态的二进制。

我们可以把打包工作建立在 Java 的基础上。

这个实践方向是 Glimmer

Glimmer DSL for SWT 能够在 JRuby 之上将 Ruby 应用程序打包到原生安装程序(如 Mac DMG/PKG/APP、Windows MSI/EXE 和 Linux RPM/DEB)中,使开发者能够给最终用户(非程序员)一个单一的文件来运行,以安装所有需要的内容,比如 JRuby(可以运行任何 Ruby 代码)、它的 JVM 依赖项,以及正在安装的应用程序:

Glimmer DSL for LibUI,它直接在 Ruby 上运行而不是 JRuby,也有一个关于打包 Ruby 应用程序的部分,你可能想要查看(它提到了 Windows 和 Mac 的打包解决方案):

以下是使用 Glimmer DSL for SWT 打包的应用程序示例,这些应用程序由最终用户安装,没有问题:

这些都是作者发来的例子。尝试跑了几个,没有成功。 还需要研究研究。

总结

如何把 Ruby 带到终端,其实一直不停的有人研究。项目生生死死。这里列举一些,供参考。

1)容器打包, 静态链接 portable ruby 思路:

2)临时文件系统思路:

3)JRuby 思路:

4)Portable Ruby 思路:

5)只打包应用脚本,指定系统 Ruby

  • platypus 只打包你的脚本,封装成 app,只适合简单脚本

6)静态语言

使用 Crystal , Ruby 语法的 Go like 语言开发应用

7)使用 Zig

这是一个问号,Zig 作为一个新语言可以作为 C 的环境,而且自己实现了所有的静态库。

不知道 Zig 作为 CRuby 的编译器会如何? 但是 Zig 目前依然在发展中。

8)使用容器

容器技术是任何语言的一个打包工具。

对于开发者友好,但是终端用户还是有门槛的。

不适合游戏应用。

9)Gem

如果都能接受用户总归要自己安装 Ruby 的设定。

把游戏、应用,封装成 gem,可以自动处理依赖、版本问题。

使用Ruby-build 在 MacOS上 编译 Portable ruby

2024年5月27日 19:31

我的 Blog

大家好,我是 Mark24。

分享下我的笔记,使用 Ruby-build 在 MacOS 上 编译 Portable ruby

设想一下,如果 ruby 可以变成 portable 的,放在 U 盘上就可以带走,传输到任何一台电脑上就可以执行。

Portable Ruby + 你的 Ruby 代码 的 zip 包,就像一个行走的独立软件。就像 Go 打包的一样。

你还可以把他们塞入 一些壳软件里。就像 Electron 那样运行(内部是个浏览器)。

当然 Ruby 社区曾经有很多方案 Traveling Ruby、Ruby Packer,都用各自的方式实现类似的效果,不过都不维护了。

下面用一个简单的方法来制作 Portable Ruby。


截止 2024-05-27 最新版本是 3.3.1 。 每个版本因为特性的不同构建是一个动态的过程。就以 3.3.1 为例。

过程偷懒,建立在 ruby-build(https://github.com/rbenv/ruby-build) 的基础上。

不论是 asdf、rvm …… 他们的背后都是 ruby-build 一个方便安装的 standalone 的工具。ruby-build 解决了大部分的问题,我们只需要找到合适的构建参数。

一、前置依赖

1.安装 Mac 的基础工具集

终端输入 xcode-select --install

2.安装上 homebrew

https://brew.sh/

获得 类似于 Linux 上的包管理工具

3.安装 Ruby 编译需要的前置依赖

# 安装前置依赖
# ruby-build 是安装工具
# openssl@3 readline libyaml gmp 是必要的依赖
# rust 是 YJIT 必要的依赖,不装就不会构建 YJIT 功能

brew install ruby-build openssl@3 readline libyaml gmp rust

二、编译

0.知识点

C 语言(CRuby 是 C 语言项目)编译一般分为 3 个基本过程

1)预处理:处理一些前置的宏替换
2)编译:把 .c 代码文件翻译成 .o 机器码文件目标文件
3)链接:把 .o 文件和系统的底层库(比如标准输入输出)正确的关联起来。生成可执行文件

链接这部,有两个基本的实现

1)静态链接
2)动态链接

静态链接比较简单,就是把所有用到的代码打包成一个整体。软件就像一个 exe 文件,带到哪儿都可以执行。
优点就是,随处执行。缺点就是体积大,更新困难,比如你依赖的系统部分有安全缺陷。你必须整体替换。

动态链接,就是软件把用到公共部分(系统、上游 lib)的部分,指他们的动态库(linux 是 so 文件, windows 是 dll 文件,mac 里是 dylib 文件)。
优点:体积小, 如果公共部分有安全漏洞,系统更新,只需要更新动态链接库文件,所有引用的软件都会获得更新。
缺点:除了无法 portable,软件运行的前提是系统拥有相应的 库。

动态链接是常态,不论是 Linux、MacOS、Windows。动态链接的实践这么多年运行的一直很好。通常库都是按照动态链接库方向来设计的。没有提供静态库。

MacOS 还禁止系统动态库进行 静态链接。
  1. 最简单的编译

关键参数:

  • $HOME/portable-ruby 是你存放的目录
  • --enable-load-relative 地址是相对目录,这对我们移动很重要
  • --with-static-linked-ext 静态链接
RUBY_CONFIGURE_OPTS="--enable-load-relative --with-static-linked-ext" ruby-build 3.2.2 $HOME/portable-ruby

2.一些优化选项

可以参考 https://github.com/rbenv/ruby-build

额外的选项

  • --with-out-ext=win32,win32ole 去掉 MacOS 上不需要的拓展
  • --disable-install-doc 关闭文档,减小体积
  • --disable-install-rdoc
  • --disable-dependency-tracking
RUBY_CONFIGURE_OPTS="--enable-load-relative --with-static-linked-ext --with-out-ext=win32,win32ole --disable-install-doc --disable-install-rdoc --disable-dependency-tracking " ruby-build 3.2.2 $HOME/portable-ruby

ruby-build 能做的更多,比如支持交叉编译

三、Portable Ruby

编译正确完成,你应该获得了 portable ruby

在拥有 依赖库的电脑上(对,我们前面解释了,系统部分是禁止 静态链接的)。

你的可以把你的 ruby 代码 + portable ruby 放在一个文件夹里。 用 一个 shell 脚本,通过相对路径连接起来执行。

比如这样

#!/usr/bin/env bash
./portable-ruby/bin/ruby ./main.rb

某种意义上, Portable Ruby + Ruby Script 和 Go、Crystal 打包的可执行文件,是一样的。就是大了一点 :D

我的 Blog

Ruby 元编程概要

2023年10月18日 23:41

前言

个人总结,方便回忆。偏向自言自语。

Ruby 的概念非常多,重点在于划分,可以让他清楚一些:

  • OOP 常规部分
  • self
  • 元编程动态修改的部分
  • 其他辅助功能

最后觉得理解 Ruby 复杂之处的关键就是 self,任何疑难杂症,确认了 self 也就能找到问题的突破口了。

“understanding self is the key to Ruby. Also the key to life”.
                                             —— Dave Thomas

一、Ruby 的面向对象基础

Ruby 的面向对象其实并不特别。基本概念和其他语言保持一致。

1)类、实例、模块

这完全是 普通的面向对象的概念

特别一点的存在:

2)作用域门:class、module、def 彼此互不可见

  • class 内部 def 通过 @ 实例变量通信
  • module、class以及 def 之间共通的只有常量

3)块、lambda

  • lambda 是匿名函数
  • 块,自带周围环境的绑定,即闭包;和 Python、JS 不同,Ruby需要有目的的使用闭包(经过我的理解,闭包是接口预留给用户的

二、Ruby 的继承

1. Ruby的模式

receiver.message

调用方法被认为是发给 receiver 的消息。

实际上,有一个 current_object 概念就是指当前的 receiver,它会被放在 self 变量里。

程序的执行过程中,遇到 module、class、def、xxx_eval、receiver.message形式显式调用 都会切换 self 执行完毕又会切换回来。

显式调用,self 会被设置为对象,方法会在 对象的链路里查找。

隐式调用,receiver 会被设置为 self ,方法查询就会在 self 的链路中查找。

理解 self 就是至关重要的。

2. receiver Ruby 查找方法的方式是固定的:

查询方式:

  • 1)单例类(如果有的话)
  • 2)类
  • 3)类的继承链条

可以通过执行的方式获得查询路径:

  • 1)单例类: xxx.singleton_class
  • 2) 类以及单继承链: xxx.class.ancestors

3. Ruby 面向对象底层逻辑:

  • 1)Ruby 的一切几乎都是对象
  • 2)Ruby 的 class 也是实例

Ruby 的对象就是一个结构体,包含:

  • 1)flags
  • 2)实例属性
  • 3)指向类

类 保存的是:

  • 1)方法
  • 2)superclass

设计与推论

树形结构

Ruby 只支持单继承;Ruby 的所有类默认继承 Object。

这两个推论不难得出:所有的类会构成一个树形彼此引用的结构;所有的类在 根 层级共享方法。

环形结构

Ruby 在文件中,即不在任何类中的方法,会被定义在 当前的对象 实例 main 上。 Ruby 的所有代码跑在了一个 Object 的实例化对象上面。

这就类似于所有的代码,定义执行在一个 Object 的 实例上下文中


main = Object.new

main.instance_eval {
	// your code
}

所有定义在 文件中的方法,最后都是 main 的 private 方法。

所有 后期复杂自定义类的 实例,寻找方法的时候,会沿着 祖先链条查找,最后会查到 Object,然后会访问到 Object 的私有方法,最后会访问到 main 上下文中定义的方法。

所有方法一个不剩,都会被查找一圈。有一个美丽的环形结构。

同时告诉我们,尽可能不要在 文件的最外层上下文中定义方法,这个和 Kernel 方法无异。

4.单例类

每一个对象除了类定义时候的方法,还有一个空间 —— 单例类

可以保存属于他自己的方法的地方

  • 1) def 定义
def instance_name.xxxx

end
  • 2) “class «” 定义形式
class << instance
	def xxx
	end
end

定义 【instance】 的 单例类

class << 【instance】, 可以用下面几种方式,来说服自己理解这个丑陋的语法:

  • 可以认为是 class << 是 单例类
  • 可以认为这种特殊语法 把 【instance】切换成了当前 self
  • 可以认为这种特殊语法,把方法注入到 【instance】的单例类中

PS: 由于 self 的存在,为了通用性 【instance】 通常会是上下文中的 self

三、Ruby 特殊的地方

Ruby 特殊的地方就是 元编程 能力,可以改变自身。 讲清楚 self 就可以讲明白 元编程

程序的执行者,就是 数据+函数;而面向对象把这两个封装成了: 类(包含:数据、方法) 元编程,是动态的塑造 类 的能力。

(笔者:没有 meta-meta programming 的原因,因为再抽象一层意义不大 :P)

我们知道了class、以及实例查找方法的固定套路、以及self

元编程由几个部分组成

1)动态调用方法

除了语法的声明式,还有内置的 API 可以调用做相同的事情。

define_method
define_class
send
# 。。。

2) 类宏: 类方法,在类声明的上下文中执行,结果是生成定义的实例方法

理解类宏的关键是:

  • 类也是实例
  • 类的声明部分,也在执行代码
  • 类方法除了被单独外部调用,也可以在类声明部分执行,用来生成实例方法

实际上只有 Ruby 精心的预留设计,才能促成这样的结果。 这些特点是有意而为之。

# 常见内置类宏举例
attr_accessor
alias

3)xxx_eval :eval的特点是可以执行字符串、block;最重要的差距是,他会改变 self

xxx_eval 的内部会切 self

  • class_eval 只在 class 中使用
  • eval 是 字符串和传入绑定
  • instance_eval 最常用,他总是在定义单例类方法 由于一切都是对象:普通对象定义的就是实例方法、如果定义在类上,就等于类方法

xxx_eval 可以接入 self 的作用域

4)modules 与 mixins

类中:

  • include:把方法放入当前 class 的继承链条,如果在 class 初始化中执行等同于拓展了 实例方法
  • extend:把方法放入 当前的单例类, class 初始化中执行等同于拓展了 类方法

至于其他的 prepend、refinement 都是辅助

5)Hooks

一些周期的钩子,比如继承

6)自省

通过读取属性,来判断 配合 =~ 内部正则来工作

7)proxy:xxx_missing

method_missing
const_missing
respond_to_missing
# 。。。

在这里可以收集失败方法,使用 其他 1)~ 6)

* 自省,来判断方法
* hooks,来判断注入阶段
* define,动态定义方法
* send,动态发送方法
* eval,求值
* 类宏,可以使用来定义方法
* super 交还给父方法,不带参数等于继承所有参数

动态生成新的类、方法

四、有效的方法 self

Ruby 的执行,用远在围绕着 self

当遇到困惑的时候:

在当前代码中,打印 self; 或者使用 instance_eval 中 打印 self

self 作为探针,可以反映出当前正在工作的对象

self 可以按图索骥:

self.class
self.class.ancestors
self.singleton_class

来查询当前的工作状态

五、DSL

self 和 instance_eval 的神奇组合

这是由 Ruby 的几个规则设定,组合下的产物:

  • 1)由于上下文中一直存在 self
  • 2)隐式调用的方法,会默认调用当前的 self 方法
  • 3)instance_eval 是会切换到 当前 self 到 instance 的上下文

这样外部的 block 可以省略 receiver 直接书写 instance 内部的方法名

这样就是 DSL 的书写范式


class Demo
  attr_accessor :cache

  def initialize(&block)
    @cache = {}
    instance_eval(&block)
  end

  def attr(name, value)
    @cache[name] = value
  end
end

d = Demo.new do
	# 这里被eval 执行,这里的 self 是对象的上下文
	# 这里 调用 attr 省略了调用者,呈现出语言形态
	# 这部分就是 DSL
	# 这部分的 程序,也可以读取文件的代码,以DSL 形式书写的文件,进行读取
  attr :hobby, "programming"
end

puts d.cache

总结

Ruby 丰富的 feature,是刻意设计,有意制造出这么多的组合效果。继承 Lisp 的基因。可以持续变化自身的语言。

Ruby 设计思想非常深邃。

  • 面向对象,强行的规定所有变量只能通过方法获取。这是使用了一种设计模式,最大可能应对未来变化。
  • 复用结构,有 class、module,可以动态的调整模块的继承顺序。环形查找结构、树形的继承结构。
  • 使用 Module管理模块,潜在为了配合:Monkey patching
  • 所有语言构建都可以改变;每一个语言组件都是对象,都可以动态的添加、删除方法。
  • DSL,eval,proxy 语言可以动态的求值,根据输入动态的生成方法
  • ……

Ruby 很适合抽象程度高的目标。

编程语言的几个能力维度

2023年4月26日 22:02

我把编程语言或者是功能深入的维度划分为八个层次。

一、 八个层次

1. 基本函数能力 —— Function

可以构建一个子函数的能力。具有基本的

  • 顺序
  • 分支
  • 循环

控制逻辑,可以整合变量、对象 到一个函数的概念中去。

2. 类/模块能力 —— Class/Module

可以把 数据、方法,整合到一个概念中。比如 类。

有基本的,数据访问、方法访问。

可以进行复用,比如实例化过程。

也可以是某种方法的集合,比如 模块概念。

可以对模块的 Mixin、Inclde 过程

总之这是一个整合 data、methods,并且可以复用 methods 的概念。 到这里进一步的把复用、封装的抽象概念进行贯彻。

3. 文件包能力 —— Package/Module

这里是站在文件角度,可以将文件中的 上面的概念,可以进行:

  • 导入
  • 导出

从而建立起一个 文件包Package 一样的能力。 可以进行多文件组织。

4. 函数式能力 —— Lambda

除了前面 类 支持多文件、多模块。

纯函数也有自己的方式,可以支持多模块、多文件支持。

从而支持函数式范式。

5. 作用域、以及跨作用域通信 —— Scope

拥有作用域概念,隔离变量。

又有某种方式可以跨域作用域,进行变量的传递、访问。

以及他们之间可以交换值。

6. 接口暴露,委托能力 —— Delegation

可以选择性的暴露,或者把暴露的方法,挂载在某个模块上。

可以进行对复杂过程、基本类、模块的隐藏。

到这里,跨文件、跨模块的各部分才能有效的进行合作。 所有的子函数才能在一起进行配合。

7. 元编程 —— Eval/Proxy

具有 Eval 求值、Proxy 拦截 两个功能,可以进行元编程能力的拓展。 可以进行动态的生成代码。

8. 设计模式和数据结构

既有的语言基础,提供:

  • 基本数据结构构造的能力
  • 基本的设计模式,实现的接口的能力

站在这个基础上,可以进行复杂模型的设计。

8.1 建立在复杂模型上,可以进行面向模型编程、面向接口编程

二、总结

一个语言,拥有或者摸索出这八个基本的层次,才能进一步的构建复杂程序。

三、程序设计原则

主要设计原则实际上只有一个:高内聚、低耦合。

任何复杂的原则都是基于这两者衍生出来的。

比如完全能体现上面的: “复用”、“可读性”、“KISS” 三个原则。

零刻GTR5-5900HX改装成Linux服务器

2023年4月12日 22:48

一、关于 零刻GTR5

beelink-gtr5

Beelink-GTR5 是一款迷你主机,采用 AMD5900HX 处理器,可以自行升级内存、硬盘、加装SSD,可以改装支持独立显卡。 拥有丰富的接口,支持多个屏幕。

噪音不大,性能给力等优点。

并且对 Linux 十分友好。我买回来后,保持了原来的 Windows11 正版系统保留使用,加装了另一块硬盘独立安装 Linux 系统,当作服务器用。

记录下过程摘要。

二、安装 Linux

1. 加装 SSD 硬盘

这款机器支持自己安装一块 SSD。

我自己安装一块SSD,这里自带的硬盘默认保持 Windows 11 系统,自己安装的 SSD 安装 Linux。

2. 选择 Linux 系统

选择了 Linux Mint

具有如下优点:

  1. 驱动没问题:Linux Mint 属于 Ubuntu 衍生版,零刻GTR5 5900HX 官方支持 Ubuntu 20.04,这意味着在此基础上的 Ubuntu 衍生版都不会出现驱动问题
  2. 不臃肿、去除商业化:Linux Mint 默认移除 Snap 应用,程序不臃肿,保持原生应用,下载速度快,占用资源少
  3. 支持4K屏幕:Linux Mint 维护的 Cinnamon 桌面平衡了占用资源和表达效果的平衡。4K、2K屏幕展示没有问题,并且占用资源相对 GNOME44 少,支持插件。
  4. 桌面高可用:Linux Mint 作为桌面,有着精致和一致性的UI、IDE、编辑器都不会出现低级的 UI 问题。
  5. 软件质量过硬:软件质量高,稳定性强。没有频繁地错误。

总结为两点:

  1. 一方面,可以作为 个人桌面 临时使用
  2. 另一方面去除显示器就可以作为占用资源极少的服务器来工作。

是一个非常实用主义的选择。

3. 安装 Linux 系统

踩坑: 请用不同发行版的官网推荐烧录工具,烧录 USB 启动盘。他们通用型没有那么强,我遇到很多因为文件无法展开,安装一半失败的情况。

简单的步骤概要:

  1. 烧录USB系统盘
  2. 插在迷你主机接口
  3. 启动机器,按 F7 进入BIOS
  4. 选择U盘启动
  5. 正常安装操作系统

总结: 走到这步,可以选择性的进入 Linux 系统

三、解决疑难杂症汇总

1. 4K屏幕下grub字体太小

官方提供了 grub 启动菜单主题,参考这里解决:grub-boot-menu

2. 默认启动 Linux

机器启动界面 F7 在 BIOS 中寻找启动顺序,把 Mint/Ubuntu 启动位置变成第一位。

3. 重启,停留在 grub 命令行界面,无法进入系统

有一个现象就是从 Linux 中 reboot 重启,总是会进入 Grub 的命令行界面,等到输入命令。

这种情况往往是因为更新了 Windows 导致分区被重写,Linux 无法找到正确的分区,从而产生的问题。常见于多分区的 Linux 电脑。

grub2

解决办法:

使用 LiveCD 作为恢复系统对计算机内部进行启动区修复。

1.制作一个 Ubuntu 系统(或衍生版)的 USB 启动盘

2.从U盘启动,进入 Try Ubuntu 界面

3.安装 boot-repair

sudo add-apt-repository ppa:yannubuntu/boot-repair
sudo apt-get update
sudo apt-get install -y boot-repair

4.运行 boot-repair 执行默认修复即可

总结: 在这里,可以确保设置完Linux为启动第一顺位,每次启动,都可以进入 Linux 系统

4. 像服务器一样断电重启

如果遇到断电,正常电脑必须手动重新开机,如何像服务器一样可以自行开机? 零刻GTR5 可以通过修改 BIOS 办到。

机器开机界面 F7 进入 BIOS

BIOS 配置路径:

Advance -> AMD CBS -> FCH Common Options -> Ac Power Loss Options

其中选项 Always Off 是默认选项,就是正常行为,断电需要手动开机。

Always On 表示通电就开机,保持最后的状态。

总结: 这里可以确保,断电,恢复之后机器依然可以正常启动到 Linux 中。

跨域问题

2023年3月17日 19:44

跨域问题(Cross-Site Resource Sharing,CORS)指的是在Web应用程序中,当一个页面从一个域名请求另一个域名的资源时,浏览器会出于安全考虑阻止其跨域请求,以避免恶意攻击和数据泄漏。

具体来说,当Web页面尝试从一个域名请求来自另一个域名的资源时,浏览器会执行一个预检请求OPTIONS,以确认请求是否被收到并是否被允许。如果Web应用程序中的CORS设置不正确,浏览器将拒绝请求,并显示错误消息。

常见的解决跨域问题的方法包括:

  • 同源策略(Same-Origin Policy):同源策略是浏览器中的一种安全机制,意思是只有在同一个域名下的Web应用程序才可以相互交互。同源策略确保在浏览器中不能从一个域中执行来自另一个域的脚本。

  • CORS设置:允许充分控制对其他域的访问。CORS使用HTTP标头来允许或拒绝浏览器请求。

  • 代理:使用代理服务器是一种不同主机间通信的方法。代理服务器充当客户端和目标服务器之间的中间人,将跨域请求代理到目标服务器,并附加响应头。代理服务器可能允许完全开放的跨域访问,或者只允许特定域的请求。

  • JSONP:JSONP是一种解决跨域问题的方法,它使用JSONP响应包装数据,并请求一个不同源的脚本。

上述方法都有其各自的优缺点,开发人员可以根据项目的实际情况去选择合适的解决方案。

CORS 设置的 标头

在CORS设置中,开发人员可以使用HTTP标头来控制跨域请求。以下是几个主要的HTTP标头:

Access-Control-Allow-Origin:指定允许访问的域列表,例如:Access-Control-Allow-Origin: https://www.example.com

Access-Control-Allow-Credentials:指出当请求带有凭证时,是否允许请求。如果值为true,则允许请求,否则不允许。例如:Access-Control-Allow-Credentials: true

Access-Control-Allow-Headers:指定浏览器允许的请求头列表,例如:Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With

Access-Control-Allow-Methods:指定允许的HTTP方法列表,例如:Access-Control-Allow-Methods: GET, POST, PUT, DELETE

Access-Control-Max-Age:指定预检请求的缓存时间(以秒为单位),以减少发送预检请求的次数。例如:Access-Control-Max-Age: 3600

以上HTTP标头是在跨域请求中最常使用的标头,HTTP标头的具体使用要根据不同的场景进行选择。开发人员应该根据具体需求选择合适的标头,以便于跨域请求的设置和控制。

Cookie相关

2023年3月17日 19:42

安全标记

JavaScript Document.cookie API 无法访问带有 HttpOnly 属性的 cookie;此类 Cookie 仅作用于服务器。例如,持久化服务器端会话的 Cookie 不需要对 JavaScript 可用,而应具有 HttpOnly 属性。此预防措施有助于缓解跨站点脚本(XSS) (en-US)攻击。

如果设置了HttpOnly,那么客户端中的JavaScript无法修改Cookie,无论是新增还是修改。HttpOnly选项是一种安全措施,可以防止跨站点脚本攻击(XSS攻击)。通过阻止JavaScript访问敏感Cookie,HttpOnly可以防止攻击者获取访问受害者账户的权限。JavaScript代码只有通过发送HTTP请求到Web服务器,然后在服务器端设置Cookie才能设置Cookie,而不能通过JavaScript直接访问Cookie。因此,如果你在后端设置了HttpOnly,客户端中的JavaScript不能新增或修改Cookie,只能在Web服务器上通过HTTP响应向客户端发送新的Cookie。

— FROM chatGPT

补充说明

如果用 Chrome 打开具有登录属性的网站,可以看到 Cookie 是有 HttpOnly 属性打钩的。

被设置的具体字段,无法读取,但是没有被设置的字段依然可以读取、修改,并且会携带上传。

Lodash的get方法模拟实现

2023年3月17日 19:24
function get(obj, path, defaultValue = undefined) {
  // 如果path是字符串,则将其转为数组
  path = typeof path === 'string' ? path.split('.') : path;

  // 遍历path数组取出obj中对应的值,如果不存在返回defaultValue
  for (var i = 0; i < path.length; i++) {
    var key = path[i];
    if(Array.isArray(obj) && !Number.isNaN(Number(key))) {
      // 如果obj是数组,且key可以转成数字,则使用索引访问
      key = Number(key);
    }
    if (!obj || !obj.hasOwnProperty(key)) {
      return defaultValue;
    }
    obj = obj[key];
  }
  return obj;
}

// 使用示例
var obj = { a: { b: [{c:1},2] } };

console.log(get(obj, 'a.b.0.c')); // 1
console.log(get(obj, ['a', 'b', 0, 'c'])); // 1
console.log(get(obj, 'a.b.1', 'defaultVal')); // 2
console.log(get(obj, 'a.b.2', 'defaultVal')); // 'defaultVal'
console.log(get(obj, 'a.b.0.d')); // undefined

Promise实现的Scheduler

2023年3月17日 14:44
class Scheduler {
  constructor() {
    super();
    this.queue = [];
    this.maxCount = 2; // 最大并发数
    this.runningCount = 0; // 当前正在运行的任务数
  }

  add(promiseCreator) {
    this.queue.push(promiseCreator);
  }

  start() {
    for (let i = 0; i < this.maxCount; i++) {
      this.run();
    }
  }

  run() {
    if (this.queue.length === 0 || this.runningCount >= this.maxCount) {
      return;
    }

    this.runningCount++;
    const promiseCreator = this.queue.shift();
    const promise = promiseCreator();
    promise
      .then(() => {
        this.runningCount--;
        this.run();
      })
      .catch(() => {
        this.runningCount--;
        this.run();
      });
  }
}

const timeout = (time) => {
  return new Promise((resolve) => {
    setTimeout(resolve, time);
  });
};

const scheduler = new Scheduler();

const addTask = (time, order) => {
  scheduler.add(() => {
    return timeout(time).then(() => console.log(order));
  });
};

addTask(1000, 1);
addTask(500, 2);
addTask(300, 3);
addTask(400, 4);

scheduler.start();

使用 async、await 实现

class Scheduler {
  constructor(maxCount) {
    super()
    this.maxCount = maxCount;
    this.queue = [];
    this.runningCount = 0;
  }

  add(fn) {
    this.queue.push(fn);
  }

  async run() {
    while (this.runningCount < this.maxCount && this.queue.length > 0) {
      this.runningCount++;
      const fn = this.queue.shift();
      await fn();
      this.runningCount--;
    }
  }

  async start() {
    while (this.queue.length > 0) {
      await this.run();
    }
  }
}

const timeout = (time) => {
  return new Promise((resolve) => {
    setTimeout(resolve, time);
  });
};

const scheduler = new Scheduler(2);

const addTask = (time, order) => {
  scheduler.add(async () => {
    await timeout(time);
    console.log(order);
  });
};

addTask(1000, 1);
addTask(500, 2);
addTask(300, 3);
addTask(400, 4);

scheduler.start();
❌
❌