Copy-on-Write(CoW、コピーオンライト)は、同じデータを複数の利用者で共有しておき、誰かが書き込もうとした瞬間にだけ複製を作る方式です。コピーの費用を「必要になるまで払わない」ので、読むだけで終わる処理では複製が一度も起きません。
この仕組みはOSのプロセス生成(Linuxのfork())、APFS・Btrfs・ZFSのスナップショットやファイルのクローン、Rust・Swiftの標準ライブラリ、そして2026年1月21日リリースのPandas 3.0まで、層の違う場所で同じ発想のまま使われています。本記事では各層での動き方を実行例で示し、CoWがかえってメモリを食う条件まで整理します。
まとめ:Copy-on-Writeの要点
- CoWは「共有 → 書き込みを検知 → その部分だけ複製」の3段で動く。読み取りしかしないデータは最後まで1つのまま
- Linuxの
fork()は親のページテーブルと子の管理構造だけを作り、メモリページは書き込まれた時点で1ページずつコピーする - APFS・Btrfs・ZFSは、上書きせず別の場所に書くことでスナップショットと瞬時のファイル複製(クローン)を実現し、ReFSもブロッククローンした共有領域では書き込み時に新しい領域を割り当てる。GNU coreutils 9.0以降の
cpは既定で--reflink=auto - Pandas 3.0ではCoWが唯一のモードになり、
pd.options.mode.copy_on_writeで無効化できない。連鎖代入は元のDataFrameを変えず、ChainedAssignmentError警告が出る - 書き込みが広く散る処理では、共有していたページがほぼ全部複製され、子プロセス1つにつき共有分がまるごと増える(子が1つのRedisなら2倍、子がN個のprefork型なら最悪N+1倍)。Redisの永続化とPythonのprefork型サーバーが典型例
Copy-on-Writeの定義と、通常のコピー・参照共有との違い
CoWは「遅延コピー」の一種です。複製の要求を受けた時点では元データへの参照を渡すだけにし、どちらかが内容を書き換えようとした時点で初めて実体を分けます。読み方は「コピー・オン・ライト」で、略称はCoWまたはCOW。Rustの標準ライブラリは同じ発想を「clone-on-write」と呼び、型名Cowに当てています。
即時コピー・参照共有・CoWの比較
データを渡す方法は大きく3つあり、CoWはその中間に位置します。
| 方式 | 複製のタイミング | 片方の変更は相手に見えるか | 読むだけの場合の費用 |
|---|---|---|---|
| 即時コピー(ディープコピー) | 渡した瞬間に全体 | 見えない | 全体の複製分 |
| 参照共有 | 複製しない | 見える | ほぼゼロ |
| Copy-on-Write | 書き込んだ部分だけ | 見えない | ほぼゼロ |
Pythonで言えばcopy.deepcopy()が即時コピー、代入が参照共有、pandas 3.0の列取り出しがCoWに当たります。利用者から見ると即時コピーと同じく独立したデータとして振る舞い、費用は参照共有に近くなります。これがCoWの特徴です。代わりに「書き込みの瞬間」を必ず捕まえる仕組みが要り、OSならページ保護、ファイルシステムならブロックの参照カウント、言語ならオブジェクトの参照カウントがその役を担います。
書き込みを検知して複製する流れ
- 共有:複製の要求に対し、同じ実体を指す参照を返す。実体の側で「何人が共有しているか」を数えるか、書き込み禁止の印を付ける
- 検知:共有中の実体への書き込みを捕まえる(OSはページフォールト、言語は変更操作の入口で参照数を確認)
- 複製:書き込む側のために該当部分だけをコピーし、参照を付け替えてから書き込む。共有者が1人になった実体は、以後コピーせずにそのまま書き換える
複製の単位は層によって違います。OSは4KiBなどのページ単位、ファイルシステムはブロック(エクステント)単位、Pandasは列(ブロック)単位、Swiftの配列はバッファ全体です。単位が大きいほど、1バイトの書き込みで払う複製費用も大きくなります。
Linuxのfork()と仮想メモリでのCopy-on-Write
CoWが最も広く使われているのは、OSがプロセスを複製する場面です。Linuxのmanページ fork(2) の NOTES にはUnder Linux, fork() is implemented using copy-on-write pages, so the only penalty that it incurs is the time and memory required to duplicate the parent's page tables, and to create a unique task structure for the child.とあります。fork()で払うのはページテーブルの複製と子プロセス用の管理構造の作成だけで、メモリの中身そのものはコピーしていません。
ページフォールトで1ページずつ複製される条件
fork()の直後、親と子は同じ物理ページを指し、カーネルはそのページを両方のページテーブルで読み取り専用にします。どちらかが書き込むとCPUが保護違反(ページフォールト)を起こし、カーネルは次の手順で処理します。
- そのページを共有しているプロセスが2つ以上なら、新しい物理ページを確保して中身をコピーし、書き込んだ側のページテーブルを新しいページへ向け直して書き込み可能にする
- 共有者が自分だけになっていれば、コピーせずに書き込み許可だけを戻す
子プロセスがすぐexec()で別のプログラムに置き換わる場合、親のメモリはほとんど書き込まれないまま捨てられるため、CoWの効果が最も大きくなります。シェルがコマンドを起動するたびに巨大なメモリをコピーしないで済むのはこの仕組みのおかげです。仮想メモリとページの管理はカーネルの中核機能の一つで、全体像はカーネルの役割と仕組みの解説で、物理メモリが足りなくなったときの退避はスワッピングとページングの違いで扱っています。
Dirty COW(CVE-2016-5195)で露呈した競合状態
CoWの処理に穴があると、読み取り専用のはずのデータに書き込めてしまいます。2016年10月に悪用が確認された Dirty COW(CVE-2016-5195)がその例で、NVDの記述はRace condition in mm/gup.c in the Linux kernel 2.x through 4.x before 4.8.3 allows local users to gain privileges by leveraging incorrect handling of a copy-on-write (COW) feature to write to a read-only memory mappingです。書き込み時のコピーと、そのコピーを捨てる処理の間の競合を突いて、一般ユーザーが読み取り専用でマップしたファイル(setuidバイナリなど)の中身を書き換え、root権限を得られました。修正はカーネル4.8.3で、安定版の4.7.9・4.4.26にもバックポートされています。
「読むだけのはずのファイルのページキャッシュを書き換える」という型の脆弱性はその後も繰り返し見つかっています。2026年の例はCVE-2026-31431「Copy Fail」の影響範囲とパッチ・緩和策で解説しています。
ファイルシステムのCopy-on-Write:APFS・Btrfs・ZFS・ReFSのスナップショットとクローン
ファイルシステムでのCoWは、既存のブロックを上書きせず、変更内容を空いている別のブロックに書いてからポインタを付け替える方式を指します。ストレージ装置やLVMのスナップショットでいうCoWは逆に、上書き前の旧データをスナップショット領域へ退避してから元の場所に上書きする方式で、ZFS・Btrfsのように新データを別の場所へ書く方式はredirect-on-write(ROW)と呼び分けられることがあります。古いブロックがそのまま残るので、次の2つの機能をほぼ無料で作れます。
- スナップショット:ある時点のポインタの束を保存しておくだけで、その時点のファイルシステム全体を読み出せる。Btrfs・ZFS・APFSが備える
- クローン(reflink):ファイルのコピーを、データブロックを共有する新しいファイルとして作る。書き込まれたブロックだけが別になる
WindowsのReFSも同じ機能を「ブロッククローン」として持ちます。Microsoftのドキュメントは、共有領域への書き込みで新しい領域を割り当てる動きを「allocate-on-write」と呼び、コピーを「low-cost metadata operation」に変えると説明しています。ZFSをOS標準で使える環境としては、FreeBSDのZFS・jail・bhyveの実装も参考になります。
cpのreflinkオプションとmacOSのcp -cによる瞬時の複製手順
GNU coreutils は9.0(2021年9月24日)でcpの既定を変え、NEWSにcp and install now default to copy-on-write (COW) if available. I.e., cp now uses --reflink=auto mode by default.と記しています。Btrfs・XFS、OpenZFS 2.2以降のZFSなら、オプションを付けなくてもクローンが試みられます。macOSではcp -cがclonefile(2)を使うオプションです。
# Linux(Btrfs・XFSなど):クローンを必須にする。非対応FSならエラー
cp --reflink=always big.bin clone.bin
# Linux:クローンを試し、無理なら通常コピー(coreutils 9.0以降の既定)
cp --reflink=auto big.bin clone.bin
# macOS(APFS):clonefile(2)で複製
cp -c big.bin clone.bin
macOS(APFS・内蔵SSD)で512MiBの乱数ファイルを複製し、df -kで空き容量を比べた結果は次のとおりです。通常コピーの所要時間は機体によって倍ほど変わりますが、使用量の差は再現します。
| コマンド | 所要時間 | 使用量の増分 |
|---|---|---|
| cp -c big.bin clone.bin | 0.009秒 | 増えない(誤差内) |
| cp big.bin full.bin | 0.858秒 | 約512MiB(524,308KiB) |
クローンした2つのファイルはFinderやlsではそれぞれ512MiBと表示されますが、実体は共有されています。片方を書き換えると、書き換えたブロックの分だけ使用量が増えます。
上書きが多いファイルではCoWを切る判断
CoWのファイルシステムでは、ファイルの途中を書き換えるたびに新しい場所へ書くため、データベースや仮想マシンのディスクイメージのように同じ場所を細かく上書きし続けるファイルほど断片化が進みます。Btrfsはこの用途のためにnodatacowマウントオプションと、ファイル単位のNOCOW属性(chattr +C)を用意しています。公式ドキュメントは、インプレース更新は上書きの多いワークロードで性能を上げるが、書き込み中にクラッシュすると部分書き込みが残りうると説明しています。またnodatacowはチェックサム(nodatasum)と圧縮も無効にします。
# 空のディレクトリにNOCOW属性を付けると、以後その中に作るファイルが対象になる
mkdir /var/lib/vm-images
chattr +C /var/lib/vm-images
lsattr -d /var/lib/vm-images
既にデータが入ったファイルにchattr +Cを付けても、既存部分の扱いは保証されません。新しいディレクトリに属性を付けてから、そこへコピーし直すのが確実です。
プログラミング言語のCopy-on-Write:Rust・Swift・C++
言語の標準ライブラリでは、CoWは「大きな値を安く渡し、変更が必要なときだけ複製する」ための型やコンテナとして現れます。
RustのCow型:借用と所有を切り替える列挙型
Rustのstd::borrow::Cowは、公式ドキュメントで「A clone-on-write smart pointer」と説明される列挙型で、借用(Borrowed)と所有(Owned)のどちらかを持ちます。入力をそのまま返せる場合は借用のまま、加工が必要な場合だけ新しい文字列を作る、という関数の戻り値に向いています。
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains('\t') {
Cow::Owned(input.replace('\t', " "))
} else {
Cow::Borrowed(input)
}
}
fn main() {
for s in ["no tabs", "a\tb"] {
match normalize(s) {
Cow::Borrowed(b) => println!("borrowed: {b}"),
Cow::Owned(o) => println!("owned: {o}"),
}
}
}
Rust 1.98.0 での実行結果は次のとおりで、タブを含まない入力は割り当てなしで返っています。
borrowed: no tabs
owned: a b
借用中の値を書き換えたくなったらto_mut()を呼びます。このとき初めて複製が作られ、以後は所有した値を直接変更します。所有した値を取り出すのはinto_owned()です。所有権と借用の基礎はRust入門の所有権・基本文法の章で扱っています。
Swiftの配列:値型のままバッファを共有する仕組み
SwiftのArrayやDictionaryは値型ですが、代入のたびに中身をコピーしているわけではありません。Swiftのリポジトリにある最適化ガイド(OptimizationTips)は、標準ライブラリのコンテナは参照カウントが1より大きく、かつ変更されたときだけ中身をコピーすると説明しています。バッファのアドレスを比べると、その瞬間が確認できます。
var a = [1, 2, 3]
var b = a
a.withUnsafeBufferPointer { pa in b.withUnsafeBufferPointer { pb in
print("same buffer before write:", pa.baseAddress == pb.baseAddress) } }
b[0] = 100
a.withUnsafeBufferPointer { pa in b.withUnsafeBufferPointer { pb in
print("same buffer after write:", pa.baseAddress == pb.baseAddress) } }
print(a, b)
same buffer before write: true
same buffer after write: false
[1, 2, 3] [100, 2, 3]
Swift 6.3.3 で-Oを付けてコンパイルした結果です。自作の構造体に大きな参照型のデータを持たせる場合は、変更前にisKnownUniquelyReferenced(_:)で共有されていないかを確かめ、共有中なら複製してから書き込めば同じ振る舞いを実装できます。
C++のstd::string:CoW実装が標準規格で使えなくなった経緯
C++では、かつてGCCのlibstdc++がstd::stringをCoWで実装していました。GCCのマニュアル(Dual ABI)は、2011年のC++標準が「forbids Copy-On-Write strings」ため実装を変えたと書いています。C++11は文字列への参照やイテレータが無効になる操作を限定し、非constのoperator[]などを対象から外したので、「書き込みの直前に複製して参照を付け替える」実装が成り立たなくなったためです。
GCCは5.1で、短い文字列を内部に持つ方式(small string optimization)の新実装を既定にし、旧実装と_GLIBCXX_USE_CXX11_ABIマクロで切り替える「Dual ABI」を用意しました。古いビルド済みライブラリとのリンクでstd::__cxx11::basic_stringの未定義参照が出るのは、この切り替えの不一致です。
Pandas 3.0のCopy-on-Write:常時有効化で変わった代入の書き方
Pandasは3.0.0(2026年1月21日)でCoWを唯一の動作モードにしました。公式のユーザーガイドにも「Copy-on-Write is the default and only mode in pandas 3.0.」とあります。2026年9月時点の最新はPyPIの3.0.6(2026年9月17日)です。Pandas 3.0での変更の全体像はPandasのできること・使い方とPandas 3.0の変更点にまとめています。ここではCoWに関わる挙動だけを扱います。
部分選択への書き込みが元のDataFrameに反映されない挙動
列の取り出しやスライスで得たSeries・DataFrameは、書き込むまでは元とメモリを共有し、書き込んだ瞬間に複製されます。以下は pandas 3.0.6(Python 3.12.13)での実行結果です。
import numpy as np
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3], "b": [10, 20, 30]})
sub = df["a"]
print(np.shares_memory(sub.to_numpy(), df["a"].to_numpy())) # True
sub.iloc[0] = 100
print(df["a"].tolist(), sub.tolist()) # [1, 2, 3] [100, 2, 3]
書き込み前は同じメモリを指していた(True)のに、subを変更してもdfは変わりません。2.x(CoW無効の既定)では、この列の取り出しは元のビューだったためdfも[100, 2, 3]に変わっていました。一方df[df["a"] > 1]のようなブール選択はコピーを返して元は変わらず、選択の方法しだいで結果が分かれていました。3.0では「別のオブジェクトを変更しても元は変わらない」に統一されています。
連鎖代入とChainedAssignmentError警告
df["b"][条件] = 値のように[]を2回続けて代入する書き方(連鎖代入)は、3.0では元のDataFrameを一切変更しません。2.xまでのSettingWithCopyWarningは削除されましたが、代わりにChainedAssignmentErrorという警告が出るので、黙って失敗するわけではありません。
df["b"][df["a"] > 1] = 0
# ChainedAssignmentError: A value is being set on a copy of a DataFrame
# or Series through chained assignment.
print(df["b"].tolist()) # [10, 20, 30] ← 変わらない
df.loc[df["a"] > 1, "b"] = 0
print(df["b"].tolist()) # [10, 0, 0]
行と列を1回の.loc(または.iloc)にまとめて指定すれば、意図どおり元のDataFrameが更新されます。inplace=Trueを付けたメソッドをdf["b"].fillna(0, inplace=True)のように列の取り出しに続けて呼ぶ書き方も、同じ理由で元に反映されません。df["b"] = df["b"].fillna(0)と代入し直すか、df.fillna({"b": 0}, inplace=True)と元のDataFrameに対して呼びます。
mode.copy_on_write の設定が無視される仕様とPandas4Warning
2.x向けに書かれた記事にはpd.options.mode.copy_on_write = FalseでCoWを切る方法が載っていますが、3.0では効きません。設定すると次の警告が出ます。
Pandas4Warning: The 'mode.copy_on_write' option is deprecated. Copy-on-Write
can no longer be disabled (it is always enabled with pandas >= 3.0), and setting
the option has no impact. This option will be removed in pandas 4.0.
設定後にpd.get_option("mode.copy_on_write")を読むとFalseが返りますが、これは値を保存しているだけで動作は変わりません。設定値を見てCoWの有無を判定するコードは、3.0では誤判定します。
2.xのコードを3.0へ移行する手順
- pandas 2.2で
pd.options.mode.copy_on_write = "warn"を設定してテストを流す。公式ガイドによれば、CoWで挙動が変わる操作のたびに警告が出るモード。影響の小さい操作でも出るため件数は非常に多く、必ず直すのは連鎖代入と列へのinplace操作 - 警告の出た箇所のうち、連鎖代入を
.loc/.ilocの1回の代入へ、列に対するinplace=Trueを代入し直しへ書き換える - 「部分を変更したら元も変わる」前提のコードは、元のDataFrameを直接
.locで更新する形に改める。逆に、防御のために入れていた.copy()は3.0では不要になることが多い - 3.0へ上げ、
ChainedAssignmentErrorが出ないことを確認する
Copy-on-Writeが裏目に出る場面:fork後のメモリ急増と対策
CoWの節約は「共有したデータの大部分が書き込まれない」という前提の上に成り立っています。前提が崩れると、共有していたページがまとめて複製され、メモリ使用量は子プロセス1つにつき、最悪で共有していた分だけ増えます(子が1つなら2倍、N個なら最悪N+1倍)。ここは各製品の公式ドキュメントが名指しで注意している場面です。
Redisの永続化とTransparent Huge Pages
RedisはRDBスナップショットやAOFの書き直しのためにfork()し、子プロセスがディスクへ書き出す間、親はリクエストを処理し続けます。親が書き込んだページだけが複製されるので、通常は追加メモリが小さく済みます。ところがLinuxのTransparent Huge Pages(THP)が有効だと、ページの単位が4KiBから2MiBに大きくなり、数千ページへの書き込みで「copy on write of almost the whole process memory」が起きると、Redisの公式ドキュメント(Diagnosing latency issues)は説明しています。同じ文書のチェックリストは次のとおりTHPの無効化を求めています。
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 設定後に Redis プロセスを再起動する
Redis 6.2以降のredis.confにはdisable-thp yesが既定で入っており、カーネル全体の設定がalwaysのときはRedisが自分のプロセスだけTHPを無効にします(madvise・neverなら何もしません)。上のコマンドでカーネル側を切る必要があるのは、Redis 6.0以前を使っている場合と、disable-thp noに変えている場合です。メモリの上限を見積もるときは、データ量だけでなく「永続化中に書き込まれる量」を足しておく必要があります。書き込みの多いインスタンスほど、保存中の一時的な増分が大きくなります。
Pythonのprefork型サーバーで共有ページが崩れる理由とgc.freeze()
Gunicornの--preloadのように、親プロセスでアプリを読み込んでからfork()で子を作るprefork型の構成では、読み込み済みのコードとデータを子と共有します。ところがCPythonのオブジェクトは参照カウントを本体に持っており、子プロセスがオブジェクトを参照するだけでカウントが書き換わります。読み取りのつもりの処理がページへの書き込みになり、共有ページが次々と複製されていきます。PEP 683は、この参照カウントの動作がforkによる共有の効果を「drastically reduce」すると書いています。
対策として公式に用意されているのは次の2つです。
- gc.freeze()(Python 3.7で追加):ガベージコレクタが追跡しているオブジェクトを永続世代へ移し、以後の回収対象から外す。公式ドキュメントは、親で早めに
gc.disable()、fork()の直前にgc.freeze()、子で早めにgc.enable()という順序を示している。GCの走査によるページの書き換えは防げるが、参照カウントの書き換えそのものは防げない - 不死オブジェクト(PEP 683・Python 3.12):
Noneや小さい整数など一部のオブジェクトの参照カウントを固定し、参照しても書き換えないようにした。アプリが作るオブジェクトは対象外なので、共有を保てる範囲は限られる
子プロセスのメモリが時間とともに共有前の水準へ近づいていく場合、原因はリークとは限らず、共有ページの複製である場合があります。max_requestsで子を定期的に作り直す運用は、この複製分をリセットする効果もあります。リークとの見分け方はメモリリークの原因の特定方法と直し方も参照してください。
CoWに頼らないほうがよい条件
- 共有したデータのほぼ全体を書き換える処理:複製の手間が後ろにずれるだけで総量は変わらず、ページフォールトの分だけ遅くなる。最初から明示的にコピーしたほうが速い
- レイテンシの上限が厳しい処理:書き込みの瞬間に複製が挟まるため、特定の1回だけが遅くなる。Redisの遅延がfork直後に跳ねるのはこのため
- 同じ場所を細かく上書きし続けるファイル:データベースや仮想ディスクをCoWのファイルシステムに置くと断片化する。Btrfsなら
chattr +Cで対象から外す - メモリのオーバーコミットを制限している環境:
fork()直後は共有でも、全ページが複製される最悪の場合を見込んだ確保を求められ、大きなプロセスのfork()が失敗しうる。Redisの管理ドキュメントがバックグラウンド保存のfork失敗への対策としてvm.overcommit_memory = 1を指定しているのはこのため
よくある質問
Copy-on-Writeの読み方と略称は?
「コピー・オン・ライト」と読みます。略称はCoWまたはCOWで、「書き込み時に複製する」という意味です。Rustの標準ライブラリのCow型は、同じ略称を「clone-on-write」の意味で使っています。
Copy-on-Writeとディープコピーの違いは?
ディープコピーは複製を要求した時点で全体をコピーし、CoWは書き込みが起きるまで複製を遅らせます。変更が相手に見えない点は同じです。Pandasでは3.0以降、部分選択が自動でCoWの動きになるため、防御目的の.copy()は多くの場合不要です。
Windowsでもコピーオンライトは使われていますか?
使われています。ファイルシステムではReFSがブロッククローンを備え、共有領域への書き込み時に新しい領域を割り当てます(Microsoftは「allocate-on-write」と表記)。メモリ管理でも、プロセスが読み込んだDLLのページなどに同じ仕組みが使われています。
Pandas 3.0でSettingWithCopyWarningは出なくなりましたか?
SettingWithCopyWarning自体は削除されました。ただし連鎖代入を書くとChainedAssignmentErrorという警告が出て、代入は元のDataFrameに反映されません。.locで行と列を1回で指定する形に直してください。
DockerのコンテナもCopy-on-Writeですか?
はい。Dockerの公式ドキュメント(Storage drivers)は、どのストレージドライバもイメージのレイヤーを積み重ねてcopy-on-write戦略を使うと説明しています。下のレイヤーにあるファイルは読むだけならそのまま使われ、最初に変更するときにそのファイルが書き込み可能なコンテナレイヤーへコピーされてから変更されます。なおDocker Engine 29.0以降の新規インストールではcontainerdのイメージストア(スナップショッター)が既定で、レイヤーの考え方は同じですが、コマンドや保存先は異なります。