🫠

gc.collect()じゃメモリは減らないかもよ

に公開3

はじめに

自分は正直全然知識がないため、勉強しながら書いています。
新たにわかったことがあったり、間違っていたらその都度修正します。
その点をご理解ください。

背景

インターンでメモリ使用量が逼迫して、それを減らすタスクをもらったが、メモリに関してよく分かっていなかったので調べた。しかし、わかりやすく解説してくれてるサイトが少なかった。

目的

pythonのメモリ管理について、わかりやすく例え話で解説していこうと思う。正直自分も全く詳しくなく、大枠しか掴んでいないため、そこは理解してほしい。

登場人物、もの、用語

  • メモリという単位のお金
  • OSお父さん
  • Pythonくん(末っ子)
  • glibc兄ちゃん(主にPythonくんの財布管理係)
  • RSSは仕送り残高のこと
  • gc.collect()は片づけのこと
  • malloc.trim(0)はお父さんに貯金箱の中のお金を返すこと

ストーリー

昔々、あるところに、お金持ちの家がありました。しかし、OSお父さんは海外赴任をしていてなかなか家に帰ってこれませんでした。
そこでOSお父さんは息子たちに電話をしました。

OSお父さん 『息子たちよ。お父さんなかなか帰ってこれなくてごめんな。お前らが生活できるように毎月100万メモリお前らの共通の財布に入れるからな。』

そう言ってOSお父さんは月100万メモリを渡すことになりました。
そのお金は息子たちの財布に入ります。
Pythonくんは早速張り切っていました。

Pythonくん 『やったー!お金好きに使い放題だ!いっぱいおもちゃ買っちゃお!』
glibc兄ちゃん 『やったな!俺が財布の管理をしてやるから、お前は好きなように使っていいぞ!』

Pythonくんはたくさんおもちゃを買いました。

何日かすると部屋が汚くなったので、gc.collect()を行いました。

そしたら何個かのおもちゃは、買ったはいいものの、一切手をつけてなく、やっぱりいらなかったみたいです。

Pythonくん 『やっぱり要らないこのおもちゃ。売ろ。新品未使用だからメ⚪︎カリ定価で売れる!』

そう言っていらないおもちゃをお金に換えて、お兄ちゃんに渡しました。

するとglibcお兄ちゃんが、

glibcお兄ちゃん 『このお金お父さんに返さなくて良くね?だって今後もおもちゃ買うかもだもんな。一旦おもちゃ買う用の貯金箱に入れとくぞ。』

と言いました。

しばらくして、OSお父さんから連絡がありました。

OSお父さん 『おーい。そろそろ一ヶ月経つけど、お金の方はどうだ?RSSが1メモリも減らないが、全部使ってるのか?』

glibcお兄ちゃん 『ちゃんと満額使ってるよ』

お兄ちゃんはそのように答えました。

Pythonくんはそれを聞いて罪悪感があったのか、このようなことを言いました。

Pythonくん 『なんかお父さんに悪いね。財布に入ってる分は生活に使ってる分だとして、貯金箱に入ってるお金は持て余した分だもんね。お父さんに返そうよ。』

glibc兄ちゃん 『わかった。じゃあ返すね。』

そう言ってmalloc_trim(0)をしました。

そしてお父さんはそのお金を受け取ってRSSを減らしました。

おしまい。

このストーリーで伝えたいこと

  • gc.collect() は「片付け」だけ。財布の中身は減らない場合がある。
  • glibcはメモリを貯金箱に入れて一応キープしているだけ。返してはいない。
  • RSSを減らしたいなら、malloc_trim()で明示的に返金する必要がある。

解説

RSSとは、プロセスが確保している物理メモリの使用量である。

glibcとは、簡単にいうと、OSとアプリの橋渡しをしているものである。

gc.collect()を実行すると、参照カウンタが0のオブジェクトを破壊して、メモリを解放しているように見えるが、実際には、参照カウンタが0のオブジェクトを破壊した後、その部分を再利用するために一時的に保持しており、OSにはそのメモリは返却しない可能性がある。

だから、gc.collect()を実行してもRSSは変わらない。

では、OSに返却するにはどうするか?

それが、malloc_trim(0)である。

これは、arena内の末尾にある空の領域をOSに返す関数である。これを実行して、一時的に保持していた領域を物理メモリ(RSS)に返している。これを実行して初めてメモリは解放される。

Discussion

dameyodamedamedameyodamedame

例えばこんなコードを書いて

hoge.sh
set -e
cat /etc/os-release
cat >main.cpp <<EOF
#include <sys/types.h>
#include <unistd.h>
#include <malloc.h>
#include <cstring>
#include <sstream>
#include <iostream>
#include <vector>

int main(int argc, char* argv[]) {
    auto pid = getpid();

    std::stringstream ss;
    ss << "ps -o pid,rss --pid " << pid;
    auto s = ss.str();
    std::cout << s << std::endl;

    std::cout << "初期状態" << std::endl;
    system(s.c_str());

    constexpr size_t N = 256;
    constexpr size_t COUNT = 16 * 256; // 16で4KiB。*256で1MiB。
    std::cout << N << "バイトずつ" << COUNT << "回確保(合計" << N * COUNT / 1024 << "KiB)" << std::endl;

    std::vector<char*> vec(COUNT);
    for(size_t i = 0; i < COUNT; ++i) {
        auto p = new char[N];
        memset(p, 0, N);
        vec.push_back(p);
    }

    std::cout << "malloc後" << std::endl;
    system(s.c_str());

    for (auto p: vec) delete[] p;
    std::cout << "free後" << std::endl;
    system(s.c_str());

    malloc_trim(0);
    std::cout << "malloc_trim(0)後" << std::endl;
    system(s.c_str());
    
    return 0;
}
EOF
c++ -g -Wall -pedantic -std=c++17 main.cpp -o main
./main

実行すると、こんな感じです。

$ sh hoge.sh 
PRETTY_NAME="Ubuntu 22.04.5 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.5 LTS (Jammy Jellyfish)"
VERSION_CODENAME=jammy
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=jammy
ps -o pid,rss --pid 6255
初期状態
    PID   RSS
   6255  3328
256バイトずつ4096回確保(合計1024KiB)
malloc後
    PID   RSS
   6255  4480
free後
    PID   RSS
   6255  3548
malloc_trim(0)後
    PID   RSS
   6255  3392

malloc_trim()はpythonとかgcとか関係ないです。

shogashoga

ご指摘ありがとうございます。
正直まだまだ理解が足りていないので間違っていたかもしれません。すみません。

malloc_trim()はpythonとかgcとか関係ないです。

なるほどです。そこら辺が一緒になっていました。

また、書いてくださったコードの実行結果を見る感じ、free実行後にRSSは減っていますね。なのでそこが間違えていました。色々勉強をして修正したいと思います。

ありがとうございました。

dameyodamedamedameyodamedame

また、書いてくださったコードの実行結果を見る感じ、free実行後にRSSは減っていますね。

伝わって良かったです。

malloc_trim()でRSSが減る理由ですが、ヒープ管理をするのはglibcなどのC標準ライブラリ実装の役割ですが、freeをしたときに必ずしもページが開放されるわけではないことに起因しています。C++だと暗黙にヒープを使うことがあり、そこを意識しながら見る必要があるので(std::coutを消すと結果が多少変わります)、先のシェルスクリプト中C++をCで書き直し、余計な考慮をしなくて良くしたものを付けておきます。

set -e
cat /etc/os-release
cat >main.c <<EOF
#include <sys/types.h>
#include <unistd.h>
#include <malloc.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>

int main(int argc, char* argv[]) {
    pid_t pid = getpid();

#define BUFFSIZE 256UL
    static char buff[BUFFSIZE];
    snprintf(buff, BUFFSIZE, "ps -o pid,rss --pid %d", pid);
    printf("%s\n", buff);

    printf("初期状態\n");
    system(buff);

#define N 256UL
#define COUNT (16UL * 256UL) // 16で4KiB。*256で1MiB。
    printf( "%ldバイトずつ%ld回確保(合計%ldKiB)\n", N, COUNT, N * COUNT / 1024);

    static void* vec[COUNT];
    for(size_t i = 0; i < COUNT; ++i) {
        vec[i] = malloc(N);
        memset(vec[i], 0, N);
    }

    printf("malloc後\n");
    system(buff);

    for(size_t i = 0; i < COUNT; ++i) {
        free(vec[i]);
    }
    printf("free後\n");
    system(buff);

    malloc_trim(0);
    printf("malloc_trim(0)後\n");
    system(buff);
    
    return 0;
}
EOF
gcc -g -Wall -pedantic main.c -o main
./main