Lambdaカクテル

集団への盲従を激しく嫌う

Common Lispでホットリロードを行うために必要な要素

SIGHUPを受け取るとリロードするという挙動はサーバプログラムによくあるものですが,Common Lispでもこれができます.しかも唯のリロードではなく,ソースコードを含めたホットリロードです.

asdf:load-systemはシステムをロードするので,実行中のシステムに対してこれを行うことでホットリロードを実現できます.

コードが大きくなってしまったので,サンプルを記載したレポジトリを用意しました.

github.com

処理の流れ

まず,リロード対象となるコードはASDFシステムである必要があります.ASDFシステムは,ASDファイルを記述してこれを読み込ませることで定義しました(code).

;;; hot-reload-sample.asd
(in-package :cl-user)
(defpackage hot-reload-sample-asd
  (:use :cl :asdf))
(in-package :hot-reload-sample-asd)

(defsystem hot-reload-sample
  :version "0.1"
  :author "Windymelt"
  :depends-on (:clack :cffi)
  :components ((:file "main"))
  :description "This is a sample for hot reload.")

ASDファイルに記載されている通り,このシステムはmain.lispに依存しています.このシステムを読み込むとmain.lispが読み込まれ,コンパイルされます.

いくつかのパーツに分けてmain.lispを見てみましょう.

まずは冒頭です.パッケージ定義に加えて,外部から呼び出されるmain関数を定義しています.main関数はHTTPサーバを立ち上げ,get-message関数が返す文字列をレスポンスとして返すようにします.

(in-package :cl-user)
(defpackage hot-reload-sample
  (:use :cl)
  (:export :main))
(in-package :hot-reload-sample)

(format t "loading...~%")

;;; 外部からここが呼び出される.
(defun main (&rest argv)
  (declare (ignorable argv))
  ;; clackを使ってHTTPサーバを起動する.
  (clack:clackup
   (lambda (env)
     (declare (ignorable env))
     ;; 常に200を返す.bodyは関数の戻り値にする.
     `(200 (:content-type "text/plain") (,(get-message)))))
  (format t "PID for this process is [~A]~%" (getpid))
  (format t "Press enter key to exit...~%")
  (read-line nil nil))

;;; HTTPレスポンスに乗るメッセージを返すための関数.
(defun get-message () "Hello, Clack!")

;;; このコードを実行しているプロセスのPIDを返す関数.
;;; from KMRCL
;;; cf. http://g000001.cddddr.org/1279896483
(defun getpid ()
  "Return the PID of the lisp process."
  #+allegro (excl::getpid)
  #+(and lispworks win32) (win32:get-current-process-id)
  #+(and lispworks (not win32)) (system::getpid)
  #+sbcl (sb-posix:getpid)
  #+cmu (unix:unix-getpid)
  #+openmcl (ccl::getpid)
  #+(and clisp unix) (system::process-id)
  #+(and clisp win32) (cond ((find-package :win32)
                             (funcall (find-symbol "GetCurrentProcessId"
                                                   :win32)))
                            (t
                             (system::getenv "PID"))))

HTTPサーバ部分はこれで以上です.このget-message関数を実行中に書き換え,ホットリロードするのが目標です.

リロード処理を記述する前に,いつリロードするかを決める必要があります.今回はSIGHUPを受けたときにリロードさせたいので,UNIXシグナルハンドラを定義するマクロを定義します.

;;; シグナルハンドラを定義するためのマクロ.
;;; from https://rosettacode.org/wiki/Handle_a_signal#Common_Lisp
(defmacro set-signal-handler (signo &body body)
  (let ((handler (gensym "HANDLER")))
    `(progn
       (cffi:defcallback ,handler :void ((signo :int))
         (declare (ignore signo))
         ,@body)
       (cffi:foreign-funcall "signal" :int ,signo :pointer (cffi:callback ,handler)))))

cffiによってCっぽい処理をしています.というかsignal関数を呼んでいるだけです. set-signal-handlerマクロにシグナル番号と呼び出したい処理を記述すると,シグナルを受信したときにその処理が実行されるようにsignal関数が呼び出されます.

実際にset-signal-handlerを使ってリロードの処理を呼び出すようにしましょう.二重にリロードするのを防ぐために,フラグも一緒に定義します. やっていることは,1番シグナル(SIGHUP)を受信したときに,フラグを使って排他処理をしつつ,asdf:load-systemを呼び出すという処理です.

;;; リロード中にリロードが発生しないようにするためのフラグ.
(defvar *reloading* nil)
;;; SIGHUP(1)に対応するハンドラを作成する.
(set-signal-handler 1 ; 1 corresponds to SIGHUP
                    (unless *reloading*
                      (format t "SIGHUP received. Reloading...~&")
                      (setf *reloading* t)
                      ;; system名を指定することでreloadできる.
                      (asdf:load-system :hot-reload-sample)
                      (setf *reloading* nil)
                      (format t "Reload done.~%")))

実際にこのスクリプトを実行します.

git clone git@github.com:windymelt/common-lisp-hot-reload-sample.git
cd common-lisp-hot-reload-sample/
ros -S . -s hot-reload-sample -e '(hot-reload-sample:main)'
Hunchentoot server is started.
Listening on localhost:5000.
PID for this process is [11020]
Press enter key to exit...
# ここでSIGHUPを送信する
SIGHUP received. Reloading...
Reload done.

get-message関数が返す文字列を書き換えてからホットリロードすると,HTTPサーバが返すレスポンスのbodyが変化するはずです.

この方式の欠点は,asdf:load-systemの処理中にわずかに処理不可能な時間が生じる(かもしれない)ということです.

たとえばこのコードでcaveman2のシステムをリロードしたとき,リクエストが きちんと全て返るかどうかを確認することはできませんでした.

目安として,Gatlingで負荷をかけながらリロードさせてみたところ,低い確率でリクエストを返せていなかったため,おそらくダメなのでしょう.

完全にホットリロードとはいえない結果ですが,クリティカルでない用途であれば十分使えるのではないでしょうか.