書いて覚えるSwift入門

第48回メモリ管理

値の値段

前回取り上げたSwiftに対する不満の1つとして、メモリ管理が言語と密結合していることを取り上げました。

密結合しているおかげで循環参照の解消などをプログラマが制御できるのですが、密結合しているということは別の手法を導入したり、メモリ管理そのものをSwiftで実装することが難しいということでもあります。

今回はメモリ管理とはいったい何なのか、いつどんなときにそれが必要なのかを確認したうえで、それを通してSwiftのよさを見直します。

Swiftの型、CPUの型

Swiftには多彩なデータ型が標準装備されているうえ、それらを組み合わせて新たな型を定義するのも容易です。しかし一度ネイティブコード(nativecode)にコンパイルされてしまえば、そこに残る型はわずか4種類。

UInt8UInt16UInt32そしてUInt64。符号付き整数?浮動小数点数?……それらは演算のときだけ、そうキャスト(cast)されているに過ぎず、メモリ上ではただの符号なし整数なのですから。そしてメモリは64bitアーキテクチャであれば264=18,446,744,073,709,551,616個のInt8からなるただの配列です。少なくとも、ユーザープログラム――の実行単位であるスレッド(thread)の視点からは。

図1は、広大な領域に見えますが、しかしその領域のほとんどは非実在。非実在領域に無理にアクセスしようとしたプログラムはOSに殺されます。これがSegmentation Fault =SEGVです。無尽蔵に見えても実は貴重な不動産を、プログラムはどう活用しているのでしょうか?

図1 メモリ領域
図1 メモリ領域

スタック(stack)と函数(function)

何百冊に及ぶ長編作品(story)もその大部分はTwitterのつぶやき1つに収まる程度の文(sentence)から成っているように、現代のプログラム(program)も数多のサブルーチン(subroutine)から成っています。Swiftを含めむしろ函数(function)と呼ばれるこの実行単位ですが、プログラムの実行は函数が函数を呼ぶ連鎖で成り立っています。だとしたら函数ごとに必要な領域をとなりに確保して、終わったら元に戻すというのは立派なメモリ管理法と言えるでしょう。

図2が、スタック(stack)。デバッガ(debugger)でよく見かけるアレです。図2では階乗(factorial)をデバッガで追っていますが、左下のスタックトレースに同じ名前の函数が並んでいることからもわかるとおり、スタックを使うことで、再帰的(recursive)に実装された函数も難なく扱えます。管理に必要なデータは2つだけ。現在実行中の函数のスタックの上限と下限。ポインタ(という名のUInt642つ。それももちろんスタックに乗せておけばいい。

図2 Stacktrace
図2 Stacktrace

こんまりメソッド[1]に引けを取らないぐらいときめきますね。実際、こんまりメソッド以前にもっとも有名になった超整理法の正体もスタックです。バイト列の代わりに書類を、メモリの代わりに本棚をそれぞれ用いただけ のものです。

ではスタックさえあればどんなプログラムも動かせるのでしょうか? 理論上はYesです。しかしスタックには大の苦手が1つあります。それは可変長のデータ。たとえば画像をロードしたいとして、1×1ピクセルのPNGであれば95bytesにおさまりますが、iMac Retina 5kのスクリーンショットは圧縮なしで60MB近くあります。スタックは通常1スレッドあたり8MBしかなく、拡張しても64MBしかありません。

しかし起こり得る最大のメモリ使用量をあらかじめ確保しないともっとヤバイことになります。そう。バッファオーバーフロー(buffer overflow⁠⁠。それがいかに簡単に起こり得るかは、次のCプログラムを試してみればわかります。

Cプログラムのサンプル
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    char s0[8] = "ABCDEFG";
    char s1[8] = "0123456";
    printf("s0@%p was ・"%s・"・n", s0, s0);
    printf("s1@%p was ・"%s・"・n", s1, s1);
    char *p = s1;
    strncat(p, "789abcde", 15);
    printf("s0@%p is ・"%s・"・n", s0, s0);
    printf("s1@%p is ・"%s・"・n", s1, s1);
    return 0;
}
シェルでの実行
% cc -Wall bufof.c && ./a.out
s0@0x7ffee8b96990 was "ABCDEFG"
s1@0x7ffee8b96988 was "0123456"
s0@0x7ffee8b96990 is "89abcde"
s1@0x7ffee8b96988 is "0123456789abcde"

それでは、このような事前に必要量が見積もれないデータはどこにおけばいいでしょう?

ヒープ(heap)と可変長データ(data)

現代OSにおける、その答えがヒープ(heap⁠⁠。ここで思い出してください。スレッドにとってメモリは有限の配列であることを。有限の配列ということは、端が2つあるということです。スタックとは反対側の領域をインテリジェントな管理者に任せて、必要な量を伝えると空きメモリの場所=アドレスを教えてくれると。スタックに積むのはこのアドレスだけでいい。

つまりメモリ管理とはこの「インテリジェントな管理者」をどう実装するかという問題であり、よってメモリ管理≒ヒープ管理という等号が成り立つわけです。

ちなみにどちらがスタックでどちらがヒープかは、両端から真ん中に成長するのであればどちらでもかまわないはずですが、スタックは高アドレスから下り、ヒープは低アドレスから上るという順になっているOSがほとんどのようです図3⁠。

図3 ヒープと可変長データ
図3 ヒープと可変長データ

mallocとfree

それでは具体的に各言語はヒープをどのように管理してきたのでしょうか? まずC言語での例を見てみましょう。C 言語自体はヒープをまったく管理しません。その代わりヒープを管理するための函数を標準装備しています。その名はmalloc。これで問題はマロく収まるでしょうか?

Cプログラムのサンプル
#include <stdio.h>
#include <stdlib.h>
#define SIZE 1024*1024

int main(int argc, char *argv[]) {
    void *where;
    int n = 1 < argc ? atoi(argv[1]) : 0;
    int f = 2 < argc ? atoi(argv[2]) : 0;;
    while(n--) {
        where = malloc(SIZE);
        printf("where = %p・n", where);
        if (f) free(where);
    }
}

これは何かというと、1つめの引数で指定した回数だけmalloc()してどこにメモリが確保されたかを表示する簡単なプログラムです。ただし引数はもう1つあって、ここの指定がなかったり0だったりすると、メモリはfree()されません。

シェルでの実行例(その1)
% ./a.out 8 0
where = 0x1099c9000
where = 0x109aca000
where = 0x109bca000


where = 0x109cca000
where = 0x109dca000
where = 0x109eca000
where = 0x109fca000
where = 0x10a0ca000
シェルでの実行例(その2)
% ./a.out 8 1
where = 0x108648000
where = 0x108648000
where = 0x108648000
where = 0x108648000
where = 0x108648000
where = 0x108648000
where = 0x108648000
where = 0x108648000

おわかりいただけただろうか。チェックアウトしない限り部屋は空室にならないのだ、と。しかも見てのとおりこの例ではmalloc()の戻り値を受けるポインタを上書きしているので、一度free()し忘れるとその領域はスレッドが生きている間は二度と取り戻せない。これをメモリリーク(memory leak)といい、忘れっぽい人間が間違いなく使いこなすには早過ぎる技術と言えるでしょう。

では次にSwiftの例を。

swiftでの例
let oneM = 1024*1024
let n = 8
for _ in 0..<n {
    var a = [Int8](repeating:0, count:oneM)
    a.withUnsafeBufferPointer {
        print($0)
    }
}
  • free()に相当する個所がないのにリークなし
  • メモリ確保だけでなく初期化も行っている。Cでいうとmalloc()はなくcalloc()だけがあるとも言える
  • 一度も上書きされていないことを検出して、変数varから定数letにせよと促してくる

メモリ返却は、forループを抜ける時点で自動でなされています。しかしどうやってそれをなしているかはプログラマからは見えません。プログラマから見えないということは、自動返却をどう実装するかは言語処理系に任されているということです図4⁠。

図4 メモリリークしないSwift
図4 メモリリークしないSwift

実はSwiftに限らず、free(フリー⁠⁠」なメモリ管理は多くの言語で採用されています。JavaScript、Perl、PHP、Python、Rubyといったスクリプト言語は、ほぼすべてそうですし、JavaやC++11以降のsmart pointerやGoやRustなど、オブジェクトコードにコンパイルするタイプの言語も増えてきました。ただしそれをどのように実現しているかは違いがあり、大きく分けて2つの流派があります。まとめて捨てる派とすぐ捨てる派。それぞれ一長一短があるのですが、紙幅オーバーフローが迫っているので続きは次回で。

Software Design

本誌最新号をチェック!
Software Design 2022年9月号

2022年8月18日発売
B5判/192ページ
定価1,342円
(本体1,220円+税10%)

  • 第1特集
    MySQL アプリ開発者の必修5科目
    不意なトラブルに困らないためのRDB基礎知識
  • 第2特集
    「知りたい」⁠使いたい」⁠発信したい」をかなえる
    OSSソースコードリーディングのススメ
  • 特別企画
    企業のシステムを支えるOSとエコシステムの全貌
    [特別企画]Red Hat Enterprise Linux 9最新ガイド
  • 短期連載
    今さら聞けないSSH
    [前編]リモートログインとコマンドの実行
  • 短期連載
    MySQLで学ぶ文字コード
    [最終回]文字コードのハマりどころTips集
  • 短期連載
    新生「Ansible」徹底解説
    [4]Playbookの実行環境(基礎編)

おすすめ記事

記事・ニュース一覧