その手の平は尻もつかめるさ

ギジュツ的な事をメーンで書く予定です

GoのHTTPクライアントがAWS NLB配下にあるコンポーネントと通信するときに5-tupleが分散しないので特定のインスタンスにしか負荷分散されないという話題

Microservicesのようなものを考えた際、Goで書かれたコンポーネントがHTTP(S)を使って他のコンポーネントと通信するという場合があると思います。
その「他のコンポーネント」がAWS NLBの配下にある時、GoのHTTPクライアントがTCPコネクションを使い回す場合があり、その状況においては特定のNLB配下のインスタンスにしかリクエストを割り振らない挙動をするという話題です。

NLB

プロトコル、ソースIP、ソースポート、宛先IP、宛先ポート、そしてTCPシーケンス番号に基いてフローハッシュアルゴリズムを用いて割り振り先のインスタンスを選択するようになっています。

ref:

For TCP traffic, the load balancer selects a target using a flow hash algorithm based on the protocol, source IP address, source port, destination IP address, destination port, and TCP sequence number. The TCP connections from a client have different source ports and sequence numbers, and can be routed to different targets. Each individual TCP connection is routed to a single target for the life of the connection.
https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html

つまり、同一のTCPコネクションが継続的に張られている状態 (すなわち5-tupleが同じ状況) では、その上を通るHTTPリクエストは常に同じインスタンス (ターゲット) に割り振られることになります。

GoのHTTPクライアント

GoのHTTPクライアントは基本的にHTTP Keep-Aliveするようになっています。デフォルトのHTTPクライアントのTransporthttp.Transportが用いられており、MaxIdleConnsあるいはMaxIdleConnsPerHostによってKeep-Aliveして使い回すコネクションの数をコントロールしています。

ref: https://engineers.fenrir-inc.com/entry/2018/11/12/153859

デフォルトではMaxIdleConnsは無制限 *1、かつMaxIdleConnsPerHost2 *2 *3 となっています。
つまりデフォルトの状況では同一ホストに対して2並列コネクションまではKeep-Aliveが有効となり、それ以上の並列リクエストについては都度コネクションを張り切りするという挙動となります。

GoのHTTPクライアントからNLB配下のコンポーネントへリクエストを送る時

以上から、並列リクエスト数が少ない時 (すなわち MaxIdleConns あるいは MaxIdleConnsPerHost 以下の時) にTCPのコネクションがHTTP Keep-Aliveによりpersistentに保たれるため、固定のインスタンスにしかリクエストが割り振られないこととなります。たとえば高々1リクエストしか同時に捌かないような時 (つまり総リクエスト数が少ない時)、そのリクエストは常に一意なNLB配下のインスタンスに振り分けられることとなります。

なので並列リクエスト数が少ない時にNLBの配下にたくさんターゲットをぶら下げていても、大半のターゲットにはリクエストが振られず遊んでいる状況となるためまったくの無駄となります。なのでうまいこと並列リクエスト数に応じてスケールアウト・スケールインできるようになっていると良さそうですね。
(ただこれはリクエスト元のGoで書かれたコンポーネントのプロセス数にも依存するとは思っており、もし大量のプロセスがいる場合はプロセスあたりの並列リクエスト数が少なくてもうまいこと5-tupleが分散する (あるいはプロセス内のコネクションが暇すぎてKeep-Aliveが切れて都度ハンドシェイクする) のでNLB配下のターゲットについては分散するとは思いますが、そもそもリクエスト数が少ない時の大量のプロセスを上げているのはリソース過剰であるのでそこが無駄では、という見方もできる気がします。)
一方で突然リクエスト数が増えた時にスケールアウトをトリガーしたとして、果たして間に合うかどうかという話題はありますが......そうなってくると遊ぶのを織り込んで余剰なターゲットをあらかじめ上げておくしかないような気もしているところです。


それはそうとして、低並列の場合でもTCPコネクションを良い感じでラウンドロビンして5-Tupleを散らす (ついでに良い感じでTCPコネクションをプールしておく)、みたいな方法は無いものですかね。自分でTransportレイヤーを書くしか無いのでしょうか? とはいえ並列リクエスト数が少ないということは総リクエスト数も少ないというわけで、その少ないリクエストをNLB配下のターゲットにまんべんなく割り振っても特に意味はないような気はしますね......ウーン。


[追記]
ありがたい助言:

なるほど、TCPレベルでのpoolをTransportレイヤで持つことを当初考えていましたが、HTTP Clientレベルでpoolすれば良いという気付きをいただきました。
[追記ここまで]

*1:MaxIdleConns controls the maximum number of idle (keep-alive) connections across all hosts. Zero means no limit. https://cs.opensource.google/go/go/+/refs/tags/go1.19.5:src/net/http/transport.go;l=192

*2:MaxIdleConnsPerHost, if non-zero, controls the maximum idle (keep-alive) connections to keep per-host. If zero, DefaultMaxIdleConnsPerHost is used.

*3:https://cs.opensource.google/go/go/+/refs/tags/go1.19.5:src/net/http/transport.go;l=58;bpv=1;bpt=1

IP Routing TableのGo実装 "go-iprtb" 書いた

GoのIP Routing Tableの実装について調べてみたところ、だいたいOSのRouting Tableを操作する系のライブラリがヒットし *1、そうではなくユーザー側コードでRouting Table相当の実装・処理をしたい時に使えそうなライブラリがパッと見当たらなかったのでそれを書いたという次第です。


さて、GoでシンプルなRouting Table実装を書くのは実に簡単で、以下のように書くだけでほぼ期待通りに動くと思います。

type RouteEntry struct {
	Destination net.IPNet
	Gateway     net.IP
	NwInterface string
	Metric      int
}

routes := map[string]RouteEntry{}
// ここでroutesにrouteを登録する

// `target net.IP` がrouting tableに含まれているかどうかをチェックする
var matched RouteEntry
var everMatched bool
for _, r := range routes {
	if r.Destination.Contains(target) {
		if !everMatched {
			matched = r
			everMatched = true
			continue
		}

		matchedMaskLen, _ := matched.Destination.Mask.Size()
		newRouteMaskLen, _ := r.Destination.Mask.Size()
		if newRouteMaskLen > matchedMaskLen { // for longest match
			matched = r
		}
	}
}

fmt.Println(matched)

この実装の問題としてはlongest matchを考慮するためにはRouting Tableのエントリをすべて線形に走査する必要があるため、Routing Table中のエントリ数が増えれば増えるほど計算量が増える、ということが挙げられます。
したがってTable中のroutes全てを1つのPrefix Treeに落としこんでやることで、対象のアドレスについてその木のパスを1回走査するだけで対象アドレスがRouting Tableにマッチしているかどうかを判断することができ、これにより計算量を減らすことができるという狙いがあります。


例えば 10.0.0.0/8, 192.0.0.0/8, そして 192.128.0.0/9 という3つのサブネットを考えたとき、それぞれの2進数表記は以下のようになります。表にはサブネットに属するゲートウェイについても記載しています。

Subnet Subnet Binary Gateway
10.0.0.0/8 00001010 00000000 00000000 00000000 GW1
192.0.0.0/8 11000000 00000000 00000000 00000000 GW2
192.128.0.0/9 11000000 10000000 00000000 00000000 GW3

太字はサブネットマスクを適用した後のアドレスを表わしています。このアドレスビット列についてTrie木を構築していくと以下のようになります。

                 R
                / \
              /     \
            0         1
           /           \
          0             1
         /             /
        0             0
       /             /
      0             0
       \           /
        1         0
       /         /
      0         0
       \       /
        1     0
       /     /
 GW1 [0]   [0] GW2
             \
             [1] GW3

† R: Root Node
†† [n]: Terminal Node

そして対象アドレスを2進数にしてこの木に対して最長マッチをさせていくと以下のような結果が得られます ([ ]で囲われた箇所は終端ノードを表わしています):

Target IP Target IP Binary Found Gateway
10.10.10.10 0000101[0] 00001010 00001010 00001010 GW1
192.10.10.10 1100000[0] 00001010 00001010 00001010 GW2
192.192.10.10 11000000 [1]1000000 00001010 00001010 GW3
127.0.0.1 01111111 00000000 00000000 00000001 N/A


今回書いたライブラリでは上記で挙げたような木構造による実装をしたところ、以下のような性能改善を実現できました。

$ go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: github.com/moznion/go-iprtb
cpu: 12th Gen Intel(R) Core(TM) i7-1280P
Benchmark_PrefixTreeAlgorithm-20        18535410                81.68 ns/op           64 B/op          1 allocs/op
Benchmark_LinearSearch-20                6148155               192.1 ns/op            96 B/op          1 allocs/op
PASS
ok      github.com/moznion/go-iprtb     2.968s

速い! 良かったですね。
なお他にもroute消去時の枝刈りなど細々としたパフォーマンスのチューニングが入っていたりもします。

ぜひご利用ください。

CのShared LibraryにしたGoのコードで簡単にダングリングポインタを発生させる

例えば以下のようなGoのコードを書き、

package main

import "C"
import (
	"fmt"
)

//export DoSomething
func DoSomething(cstr *C.char) {
	fmt.Printf("Go: %s\n", C.GoString(cstr))
}

func main() {
}

go build -buildmode c-shared -o libdosomething.so ./ としてCのShared Libraryを生成して、以下のようにCのプログラムに組み込みます。

#include <stdio.h>
#include <stdlib.h>
#include "libdosomething.h"

int main() {
    char *str = (char *)malloc(sizeof(char) * 4);
    str[0]='f';
    str[1]='o';
    str[2]='o';
    str[3]='\0';

    printf("C: %s\n", str);
    DoSomething(str);

    free(str);

    return 0;
}

そして gcc -L . main.c -ldosomething とこのCのコードをコンパイルし、LD_LIBRARY_PATH=. ./a.out のように実行すると

C: foo
Go: foo

という風に、もちろんこのコードは期待通りに動作します。

さて、Go側の DoSomething() の処理をgoroutineを使うように変更してみましょう。

package main

import "C"
import (
	"fmt"
	"time"
)

//export DoSomething
func DoSomething(cstr *C.char) {
	fmt.Println("Go: DoSomething is called")
	go func() {
		time.Sleep(3 * time.Second)
		fmt.Printf("Go: %s\n", C.GoString(cstr))
	}()
}

func main() {
}

そして説明のためにCのコードも以下のようにします。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "libdosomething.h"

int main() {
    char *str = (char *)malloc(sizeof(char) * 4);
    str[0]='f';
    str[1]='o';
    str[2]='o';
    str[3]='\0';

    printf("C: %s\n", str);
    DoSomething(str);

    free(str);

    printf("C: C string has been freed and it's sleeping...\n");

    sleep(5);

    return 0;
}

そして先程と同じようにCのプログラムをコンパイルして実行すると、

C: foo
Go: DoSomething is called
C: C string has been freed and it's sleeping...
Go: 3/_

という風に、めでたくGoの C.GoString(cstr) がダングリングポインタを参照したおかげでおかしな文字列が出力されました。

理由としてはおわかりのように単純で、goroutineの中でsleep (これは何らかの処理を模しています) している最中にGoの関数の引数として渡されたCの char *str がCの側で解放されてしまったため、いざsleepから目覚めてgoroutineの中で C.GoString(cstr) を実行するとダングリングポインタを参照してしまう、ということになります。なまじ変数をGoの世界に引き込んだので油断してしまい、Goの関数に渡された時点でGo世界にallocateされてcopyされているだろうとタカをくくっていたところそんなはずは無く、そりゃ *C.char なんだから明らかにそうなんですが、まあ普通にpointerでしたね......という感じです。

これを避けるためには以下のように同期的に (すなわちgoroutineの外で) Goの文字列にしてしまえば良いでしょう。goroutineの引数としてキャプチャしてしまっても良いと思います。

func DoSomething(cstr *C.char) {
	gostr := C.GoString(cstr)
	go func() {
		time.Sleep(3 * time.Second)
		fmt.Printf("Go %s\n", gostr)
	}()
}


こうして見ると原因は単純ですし、この例のようにsleepを使った露骨なコードだとわかりやすいのですが、実際のコードでこういう事が起こるとなかなかわかりにくいですね。ハマりました。疲れますね。

openapi-generator を使って go のファイルを生成した時に出てくる `var _ context.Context` の謎

openapi-generator を使って go のコード生成をすると、

// Linger please
var (
	_ context.Context
)

という一見不要そうな変数宣言がコード中に出てきます。これはなんなのでしょうか? 「残しておいてね」とは書いてありますが……

TL;DR

たぶんこれ過去のワークアラウンドがそのまま残っている感じだと思われます。まあ特に気にする必要もなさそう……

<追記>
このワークアラウンドの残滓を消すパッチを送ってみた。
github.com

^ 送ってみたところマージされたのでこの謎は過去のものになりつつあります。
</追記>

詳しく見てみる

というわけでコードを掘ってみましょう。リビジョンについては今日 (2022-02-16) 現在の最新である 986446c1d5e7b2c16c667ed18b6c95b2679268f5 から遡っていきます。

なるほど、ここに該当するコードがありますね。blameしていきましょう。

openapi-generator/modules/openapi-generator/src/main/resources/go/api.mustache の一番古いコミットを見てみるとここになるようです:

ここにもこの変数宣言はまだありますね。つまりこれが最古のコミットで、そこに「ある」ってことは「ある」んだよ、ガタガタ言うな、ということでしょうか……
とまあそうではなく、というのもこのファイルは openapi-generator/modules/swagger-codegen/src/main/resources/go/api.mustache から改名されているので一見すると歴史が途絶えてしまっているようですが、これよりも古いコミットはちゃんとあります。更にこのファイルの歴史を辿ってみましょう。
しかし swagger から openapi に名前が変わっているのは歴史を感じますね。

で、掘っていくとこのコミットに行き当たります: 3ed1aa8e79687bed63dfa064de27317273080471

この diff を見ると、件の変数宣言はここが始祖のようです。それはそうとして golang.org/x/net/context が使われていたりしてなんだか懐しいですね。

で、このテンプレートファイルを読んでみると {{#hasAuthMethods}}ctx context.Context, {{/hasAuthMethods}} という条件分岐があるのがわかります。
つまり、この条件が false になるとこの部分が欠落し、そうなった時に import "golang.org/x/net/context" があるとコンパイラに未使用の import があると怒られるので、そのワークアラウンドに無名変数 (_ って無名変数って名前で良いんでしたっけ) として context.Context を変数宣言することで import 未使用コンパイルエラーを回避しているように読みとれます。なるほど〜。 *1


さて翻って最新のコードを再び見てみましょう。

このコードを読むと context.Context は常に使われているように見えるし、わざわざ var _ context.Context を付けておく必要もないように見えます。これパッと見不要そうに思えるんですが、今でも残しておく必要あるんですかね? 後でパッチでも投げてみようかしら……

結論

// Linger please
var (
	_ context.Context
)

はかつてのワークアラウンドの名残 (だと思われる)。

*1:とはいえコミットログにその旨は書いていない。コミットログの issue 番号も恐らく前の repository のものなのでどれと紐付いているかわからない、という感じなのであくまでコードを読んだ上での推察です

古いaws/aws-lambda-goでAWS Lambdaのcontainer image runtimeを使うと刺さる

具体的に言うと、aws/aws-lambda-go@v1.18.0よりも前のバージョンでAWS Lambdaのcontainer image runtimeを使うとハンドラが呼び出されず、タイムアウトするまで刺さります。

例えば以下のような非常に簡単なLambda Functionをデプロイした時、

package main

import (
	"context"
	"fmt"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func handler(ctx context.Context, event events.DynamoDBEvent) error {
	fmt.Println("CALLED")
	return nil
}

func main() {
	lambda.Start(handler)
}

古いaws/aws-lambda-goを利用していると、以下のログのように CALLED は表示されずタイムアウトまで実行を引っぱります。つまり handler() が呼ばれることなくタイムアウトをしているという挙動であることがわかります。

START RequestId: ...snip... Version: $LATEST
END RequestId: ...snip...
REPORT RequestId: ...snip...	Duration: 3003.52 ms	Billed Duration: 3000 ms	Memory Size: 128 MB	Max Memory Used: 10 MB	
2021-12-20T02:49:56.683Z ...snip... Task timed out after 3.00 seconds

このライブラリのバージョンをv1.18.0以降のバージョンにする *1 と、しっかりとハンドラが呼ばれて意図通りの動きをします。

START RequestId: ...snip... Version: $LATEST
CALLED
END RequestId: ...snip...
REPORT RequestId: ...snip...	Duration: 9.12 ms	Billed Duration: 924 ms	Memory Size: 128 MB	Max Memory Used: 26 MB	Init Duration: 914.31 ms	

go1.xで既に動いているLambda FunctionをそのままContainerに移植してみたところ、この問題で絶妙にハマってしまいました。Lambdaの実行ログには特にそれらしいエラーメッセージも出てこないし、そもそもハンドラ関数が呼ばれないのが謎すぎた……。


それはそうとしてv1.18.0のリリースノートにはそれらしい変更について言及が無いんですが、どれが該当する変更なんでしょう。これなのでしょうか? https://github.com/aws/aws-lambda-go/pull/298
[追記]
上記の件はやはりこのPull Requestが関連するとのこと https://github.com/aws/aws-lambda-go/pull/298


[追記ここまで]

さておきライブラリのバージョンは上げないと駄目ですね。ヤル気があるので最新まで上げました。以上です。

*1:つまりv1.18.0は大丈夫

Go Genericsを使ってgo-optionalを書いた / Go Generics感想

Go Genericsがどんなもんか試してみたかったので、これを使ってOptionの実装を書いてみました。

github.com

基本的な使い方としてはSynopsisを読んでもらえばわかると思いますが、ユーティリティとしては

  • IsSome()
  • IsNone()
  • Take()
  • TakeOr()
  • TakeOrElse()
  • Filter()
  • Map()
  • MapOr()
  • Zip()
  • ZipWith()
  • Unzip()
  • UnzipWith()

あたりを取り揃えております。examplesも併せてご覧いただくとおおよその使い方の雰囲気が掴めると思います。
利用のためにはまだunstableな最新版 (go1.18) を使う必要があるので、gotipとかを使って新しい処理系を引っぱってくる必要があります。

で、GoのGenericsを使ってみた感想としてdefault valueとかconstraintとかどうするんだよと悪戦苦闘していたのですが、id:codehexさんの書かれた記事にほぼほぼ書かれていることを一通り終わった後に気付きました。

zenn.dev

で、概ね同じような所感を持ったのですが、それだけだと芸が無いので僕個人としてのGenericsを使ったときの感想について記しておきます。

メソッドに型パラメータが持てない

例えばこのようなメソッドについて考えてみましょう。

type Something struct {
}

func (s *Something) Echo[V any](v V) V {
	return v
}

なんとなく構文的にはvalidなように見えますが、これをコンパイルすると

./main.go:6:25: methods cannot have type parameters
./main.go:6:26: invalid AST: method must have no type parameters

というエラーが吐かれます。メソッド (つまりレシーバがある) 時にはそのメソッドに対して型パラメータが付けられないということのようですね。
例えば以下のようにメソッドにすることをやめるとコンパイルは通るようになります。

func Echo[V any](s *Something, v V) V {
	return v
}

こういった実挙動のため、go-optionalでは Map() 等の関数実行時の型パラメータに依存するユーティリティについてはメソッドではなく関数として実装しています。
ref: https://github.com/moznion/go-optional/blob/b0ada9baa88672d2f0fc11a1487842da170b5f92/option.go#L83

なんとかジェネリクスのconstraintをtype assertionで分岐させられないか

例えば、go-optionalでは「Option[T]の値が、Tの或る値を持っているか」を確認するメソッドである Contains(v T) を実装しようと思ったのですが

type Option[T any] struct {
	value T
	exists *struct{}
}

func (o Option[T]) Contains(v T) bool {
	if o.IsNone() {
		return false
	}
	return v == o.value
}

というふうに素朴に実装すると ./option.go:20: invalid operation: cannot compare v == o.value (operator == not defined on T) という風なコンパイルエラーが出てきます。そりゃそうだ。

これについては上のcodehexさんの記事にもありましたが、現状あるconstraintを満足させるためには別の型 (型パラメータ) を付けて実装する必要があります。
たとえばこれだと通る。

type ComparableOption[T comparable] struct {
	value T
	exists *struct{}
}

func (o ComparableOption[T]) Contains(v T) bool {
	if o.IsNone() {
		return false
	}
	return v == o.value
}

あくまで例えばの話ですが、anyについてtype assertionのようにconstraintのチェックができれば便利だよな〜と思い、例えば以下のように……

// o is a value of Option[T any]
v, ok := o.(Option[T comparable])

とはいえまーこれあんま筋良くない気がするな、コンパイル時のtype erasureとかもあるだろうし……

型パラメータにタプルを持たせられない

例えばこういう書き方はできません。

func Zip[T, U any](opt1 Option[T], opt2 Option[U]) Option[(T, U)] {
	...
}

まあそういうもんですよ、と言われたらその通りなのでgo-optionalでは Pair という構造体を用意して Option[Pair[T, U]] を返却するようにしました。

ref: https://github.com/moznion/go-optional/blob/b0ada9baa88672d2f0fc11a1487842da170b5f92/option.go#L108

pkg.go.dev がgodocを読み込んでくれないっぽい?

https://pkg.go.dev/github.com/moznion/go-optional

stableなruntime versionではまだ未定義の構文が多く含まれているからか、godocが解釈できないっぽい? pkg.go.dev に一向にドキュメントが出てこなくて地味に困っています……

[追記] バイナリのフットプリントについて

Genericsを使ったときと使わないときでビルドされたバイナリのフットプリントに差が出るのだろうか、という素朴な疑問があったので雑に比較。
環境は go version devel go1.18-4083a6f Wed Nov 17 14:10:29 2021 +0000 darwin/amd64

genericsなし:

package main

import "fmt"

type SomethingStr struct {
        v string
}

type SomethingInt struct {
        v int
}

func main() {
        s := SomethingStr{
                v: "foo",
        }
        i := SomethingInt{
                v: 123,
        }

        fmt.Println(s)
        fmt.Println(i)
}

genericsあり:

package main

import "fmt"

type Something[T any] struct {
        v T
}

func main() {
        s := Something[string] {
                v: "foo",
        }
        i := Something[int] {
                v: 123,
        }

        fmt.Println(s)
        fmt.Println(i)
}

結果:

-rwxr-xr-x   1 user  user  1846368 11 18 11:11 generics*
-rwxr-xr-x   1 user  user  1846368 11 18 11:27 no_generics*

1バイトも変わらぬ結果に。面白いですね。とはいえこれは最適化戦略やコードに依存する気がするのでもっと複雑なコードを書くと変わってくる可能性がある気が……あんま信用しないでください。



だいたいそんな感じでした。手習い的にGoのGenericsを使ってみましたが、けっこうシンプルかつ現時点でも普通に使えるな、という肌触りです。
ここは型の推論が効くだろ (他の言語だと効きそうに見える)、と思って型パラメータの記述をサボると「型パラメータを明示しろや」とコンパイラに怒られたりもするわけですが、まあそこはgoらしさ・シンプルさを優先したということなのでしょう。機能と実装複雑性・コンパイルタイムのトレードオフという見方をしました。

jitterをかけたtickerを提供するgoのライブラリjickerを書いた

github.com

定期的に実行したい何かがあって、そしてそのインターバルにjitterが入っていてほしいということがしばしばあり、必要な時に都度そういうコードを書いていたのですが、毎度書くのもしんどいな〜と思ったのでこの度ライブラリにしたという次第です。

READMEのUsageに書いてあるとおり、

package main

import (
	"context"
	"log"
	"time"

	"github.com/moznion/jicker"
)

func main() {
	ctx, cancelFunc := context.WithCancel(context.Background())
	defer cancelFunc()

	c := jicker.NewJicker().Tick(ctx, 1*time.Second, 0.05)
	for t := range c {
		log.Printf("tick: %v", t)
	}
}

というふうに Tick() を使用すると、1秒に対して±5% jitterがかかったインターバル (すなわち0.95秒から1.05秒の間でランダムなインターバル) おきにtickしてくれるtickerを得ることができます。

package main

import (
	"context"
	"log"
	"time"

	"github.com/moznion/jicker"
)

func main() {
	ctx, cancelFunc := context.WithCancel(context.Background())
	defer cancelFunc()

	c, err := jicker.NewJicker().TickBetween(ctx, 1*time.Second, 2*time.Second)
	if err != nil {
		log.Fatal(err)
	}
	for t := range c {
		log.Printf("tick: %v", t)
	}
}

一方別の関数 TickBetween() を使用すると、任意の区間でjitterがかかったインターバル (この場合1秒から2秒の間でランダムなインターバル) おきにtickしてくれるtickerがやってきます。


「実家」のような名前のライブラリですが今年のゴールデンウィークは実家に帰れません。世界が大変ですね。
小粒なライブラリですが、ぜひご利用くださいませ。