簡単な例で C/C#/Rust のメモリ管理を比較

C言語C#と Rustとで、動的メモリ管理の考え方の違いと、そのメリット・デメリットについて考えます。

(1) C言語の場合

C言語の場合、動的メモリ(ヒープ)は malloc関数で確保し、free関数で解放します。確保したメモリはプログラマが責任をもって解放しなければなりません。

下記のコードでは hoge関数内で確保したメモリを呼び出し元の main関数に返し、main関数で利用してから解放しています。

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>

char* hoge(int cnt)
{
    char* data = (char*)malloc(256); // メモリ確保
    if(data != NULL) sprintf(data, "HOGE %d\n", cnt);
    return data;
}

int main(void)
{
    int cnt = 0;
    while (1)
    {
        char* data = hoge(cnt);
        printf(data);
        cnt++;
        free(data);  // メモリ解放
    }
}

もしも解放を忘れた場合、無駄に確保されたままのメモリ使用量がどんどん増大していき、パフォーマンスの低下を招いたり、最悪の場合は異常終了したりします。これがメモリリークです。

    while (1)
    {
        char* data = hoge(cnt);
        printf(data);
        cnt++;
//      free(data);  ← メモリ解放もれ
    }

この例では、30秒ほどでメモリ使用量が400Mバイトにまで達してしまいました。

この例のように、一般にメモリの確保と解放は異なるスコープでおこなわれることも多いので対応関係が自明ではなく、注意深く設計する必要があります。


(2) C#の場合

C#の場合、プログラマが明示的にメモリの解放をおこなう必要は基本的にはありません。かわってガベージコレクタ (GC) というものが裏で暗躍しており、適当なタイミングで不要になったメモリを解放します。

下記のコードは、先ほどのC言語のコードとおおむね同等のものですが、メモリ解放の処理はどこにも書いてありません。メモリ解放はGCに任せます。

using System;
using System.Text;

namespace Hoge
{
    class Program
    {
        static byte[] hoge(int cnt)
        {
            byte[] data = new byte[256]; // メモリ確保
            var hogeData = Encoding.ASCII.GetBytes("HOGE " + cnt.ToString());
            Array.Copy(hogeData, data, hogeData.Length);
            cnt++;
            return data;
        }

        static void Main(string[] args)
        {
            int cnt = 0;
            while(true)
            {
                byte[] data = hoge(cnt);
                var str_data = Encoding.ASCII.GetString(data);
                str_data = str_data.Replace("\0", "");
                Console.WriteLine(str_data);
                cnt++;
            }
        }
    }
}

これを実行するとおよそ2秒ごとにGCが働きました。C言語の場合のようなメモリリークは生じません。


(3) Rustの場合

Rustの場合、プログラマが全責任を持つのでもなく、さりとてGCも存在しません。そのかわりに Rust にはメモリの所有権 (ownership) という概念があります。ある動的確保されたメモリに対して所有権を持つ変数は必ずただ一つだけです。その変数の寿命(スコープ)が終わるとき、所有しているメモリは解放されます。

ただし、所有権は他の変数に移動(ムーブ)させることができます。代入文、関数への引数渡し、関数からの戻り値の受け取りのときに所有権が移動します。また、所有権を移動させなくても、他の変数が所有しているメモリを参照することはできます。参照しているだけの変数の寿命が終わっても参照先のメモリが解放されることはありません。

下記のコードは、先ほどのC言語C#のコードとおおむね同等のものですが、メモリ解放の処理はどこにも書いてありません。しかしGCがなくてもメモリリークは発生しません。

fn hoge(cnt: i32) -> Vec<u8>
{
    let mut data: Vec<u8> = vec![0; 256];  // メモリ確保
    let hoge_data : Vec<u8> = format!("HOGE {}", cnt).into_bytes();
    for i in 0..hoge_data.len() {
        data[i] = hoge_data[i];
    }
    return data; // 所有権が移動
}

fn main()
{
    let mut cnt = 0;
    loop
    {
        let data = hoge(cnt);
        let mut str_data = String::from_utf8(data).unwrap();
        str_data.retain(|c| c != '\0');
        println!("{}", str_data);
        cnt += 1;
        // ここでメモリ解放
    }
}

hoge関数でメモリが確保された時点では、その所有権はローカル変数 data にあります。この変数の寿命は hoge関数の末尾で終わりますが、return によってメモリの所有権は呼び出し元 (main 関数) のローカル変数 data に移動します。 そしてこの変数はだれにも所有権を移動させることなくloop節の末尾で寿命が終わるので、このとき所有していたメモリは解放されます。

上記の例ではC#とコード上の大きな違いが感じられないかもしれませんが、Rustではメモリの所有権について意識する必要があります。例えば、下記のようなコードではコンパイル時にエラーになります。

        let data = hoge(cnt);
        let mut data2 = data; // ここで所有権が移動するので、以降はdataは使えない
        data2[0] = 'B' as u8;
        let mut str_data = String::from_utf8(data).unwrap(); // ここでエラー

このような場合にはコピーするか参照するかになります。

        let data = hoge(cnt);        
        let mut data2 = data.clone(); // コピー
        data2[0] = 'B' as u8;    // data[0]は変更されない
        let mut str_data = String::from_utf8(data).unwrap(); // HOGEのまま
        let mut data = hoge(cnt);
        let data2: &mut Vec<u8> = &mut data; // 参照 (所有権は移動しない)
        data2[0] = 'B' as u8;    // data[0]も変更される
        let mut str_data = String::from_utf8(data).unwrap(); // BOGEになる

まとめ

言語 動的メモリ管理の方式 メリット デメリット
C言語 自己責任 無い メモリリークの危険性が高い
C# GC ・あまり深く考えなくていい GCが必要
・定期的にGCが実行される
GCが実行されるまでゴミがたまる
Rust 所有権 GCが不要
・ゴミがたまらない
・安全
・所有権を意識する必要がある
 (のが取っつきにくい)

C#のデメリットは、デスクトップのGUIアプリなどを作成する場合にはあまり気にならないかもしれません。リソース(CPU/メモリ)の制約が厳しくGCを載せられない組込み系や、ひたすらパフォーマンスを求められる処理エンジン系、サーバ系、またOSそのものなどでは Rustの方式が有利になりそうです。

おまけ

ちなみに上記の例では Rust の実行速度がひどく遅いのですが、これは標準出力( println! )がバッファリングされていないためのようです。