Open4

Ribbon: クロージャの循環参照をどうにかしたい

okuokuokuoku

リファレンスカウントをちゃんとやってるのにメモリリークする理由は、クロージャの循環参照だった。これはSwiftのような他の言語でも問題になる:

https://zenn.dev/mhackit/articles/a0b1c6e780c3c6aabe45

一旦何らかのヒューリスティックを導入して緩和できないか検討する。

okuokuokuoku

最小コードを作る

  • リークする
(define (apply-dummy0 f)
  (letrec ((x (lambda ()
                   (none 0))))
    (x)))
  • リークしない
(define (apply-dummy0 f)
  (let ((x (lambda () ;; ★ letrecをletにするとリークしなくなる
                (none 0))))
    (x)))

実際には letrec はマクロとして実装されていて、

(define (apply-dummy0 f)
  (let ((x #f))
   (set! x (lambda () (none 0)))
   (x)))

と等価であり、この letset! の組合せでもリークは発生する。

okuokuokuoku

Visual Studioを使ったメモリリークの追跡

今回最小コードを作るにはVisual Studio 2022のメモリデバッガを使用した。

スナップショット間の比較機能を使用すると、

ExClose (クロージャを作成するnative側関数) で作成されたオブジェクトがリークしていることが観察できる。

okuokuokuoku

良いアイデアが出ない

まぁちゃんとしたGCに頼れば当然解決するわけだけど、さすがに循環参照がそこまで出ることは想定外なのでなんとかしたい。現状だとただのループで絶対に循環参照が出ることになる。

今回のケースに限って言えば、Swiftの例のように弱参照にしてしまうことで解決することは可能だが、これを一般に適用できるかは何とも言えない。プログラマーが明示的に弱参照になっていることを意識しなければならないので、暗黙にやってしまうのは安全でない(はず)。ただ、実は今回のような named-let ケースは絶対に安全なんではないかという気がしてならない。。

定数伝搬を実装して、 set! 自体を消してしまうという手は考えられる。こうすれば参照自体が作られなくなるので循環参照の問題は解決する。(実際には、コードシーケンスの内部で循環参照が発生するが、コードシーケンスは通常解放されないので問題にならない) コンパイラで (set! ... (lambda ...))(set! ... (quote ...)) のパターンを定数の set! とみなし、これらは定数の伝播と見做してコード上で事前に置き換えてしまえば元々の set! は削除できる。

Clojureの looprecur https://clojure.org/reference/special_forms#recur のようなプリミティブをサポートしてクロージャ自体を作らないようにするということも考えられる。 ...が、流石に言語にまで手を入れてしまうのは考えものと言える。。

... いったん普通にGCを実装して、その後定数伝搬によって letrecset! を消し込むのが良いかな。普通の循環参照ケースを救うにはGCしか無いので、どっちみちGCは必要だし。