什么是copy-on-write

Copy-on-write,写时复制,简称COW,是一种资源管理技术。引用维基百科的说明:

写入时复制(英语:Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被建立,因此多个调用者只是读取操作时可以共享同一份资源。

fork()的内存语义

Copy-on-write最贴切的例子就是fork()系统调用了,来看下fork()系统调用的内存语义:

从概念上讲,可以将fork()看作是创建父进程的文本段、数据段、以及堆和栈的拷贝。

实际上,在一些早期的UNIX实现中,这种拷贝确实是按字面意思来执行的:拷贝父进程的内存到swap,创建一个新的进程映像,使swap出来的映像成为子进程,而父进程则保留自己原先的内存。

但是,将父进程的虚拟内存简单的拷贝到子进程会造成大量的浪费,原因有很多。一个很常见的原因是,fork()之后一般会立即调用exec()系列的函数,用一个新程序替换子进程的文本段,重新初始化子进程的数据段、堆和栈。因此,大多数现代UNIX实现,包括Linux,使用了两种技术来避免这种浪费:

  1. 内核将每个进程的文本段标记为只读,这样进程就不能修改自己的代码了。因此,父进程和子进程可以共享相同的文本段。 fork()系统调用通过构建一组特定于子进程的page-table条目来为子进程创建文本段,这些条目引用了父进程正在使用的相同的虚拟内存页帧。
  2. 而对于父进程的数据段、堆和栈对应的page,内核采用了copy-on-write技术。 内核通过一系列的设置,使子进程中这些段的page-table条目与父进程引用相同的物理内存页,并且将其标记为只读。 在fork()之后,内核会捕获父进程或子进程对以上任何一个页面的修改尝试,并创建即将被修改的page的拷贝。新拷贝的page被分配给触发错误的进程,并且适当调整子进程与之对应的page-table条目。 此后,父进程和子进程可以互不影响的修改各自的page拷贝。 copy-on-write如下图所示:

COW在游戏中的应用

Copy-on-write在游戏开发中也是很有用处的,比如暗黑血统的跑商系统。

跑商的需求是这样的:

  • 游戏主城内的NPC会随机售卖一些物品。
  • 主城的不同分线的NPC所售卖的物品种类和数量起初是完全一致的。
  • 不同分线的物品共用一套价格。
  • 某一条分线的买卖操作不会影响其他分线。
  • 物品种类和数量需要定时刷新。

有一点需要注意,主城分线的数量是不确定的,而且会随时动态变化:

  • 一条分线的最后一个玩家离开会导致分线的销毁。
  • 玩家进入主城时如果所有的分线都满了,则会创建一条新的分线。

初始值一致,但修改互不影响,这跟fork()的内存语义是非常相似的,这类需求非常适合采用copy-on-write技术来实现。

跑商系统的全局变量定义:

-- 定时刷新的物品模板,每条分线上的物品第一次被用到时,会从这里克隆一份
-- { npc_id = { item_id = { item_id, item_num, item_price }, ... }, ... }
g_template_items = {}

-- 每条分线上的物品第一次用到时(该分线上玩家第一次打开NPC售卖界面),
-- 克隆g_template_items并以channel_id为key存入g_npc_items
g_npc_items = {}

-- 玩家卖给NPC物品时的价格,所有分线共用一套价格
-- { npc_id = { item_id = price, ... }, ... }
g_items_price = {}

每次刷新物品只需要生成一份物品的模板即可,而不用每条分线都生成或拷贝一次。有些分线可能并没有玩家在跑商,为其生成一份数据纯属浪费。当某条分线的玩家开始跑商时,才为其真正拷贝一份物品的数据以供修改。

使用copy-on-write技术可以避免性能上的隐患,假如游戏火爆,在线玩家很多,主城分了n条线,物品的信息量又非常大,每次刷新都为所有分线生成物品信息的话说不定会导致服务器卡顿。