Python 3.10 の開発(お掃除)に参加しよう

訂正

昔から deprecate されているのにずっと生き残ってるヤツたちはクセモノのぞろいで、全然初心者向けではありませんでした。

代わりに、Docディレクトリを deprecated-removed::grep して、 3.10 で削除する予定になっているものを削除する方がずっと楽なので、そちらに挑戦してみてください。削除する手順は下の記事のままで大丈夫です。


Python 3.9 がベータに入り、masterブランチはPython 3.10の開発に入りました。

はっきりとした区分はないものの、Python 2.7との互換性のために長くdeprecated状態を維持していたメソッドの削除に踏み切るバージョンになりそうです。そこでこんなIssueを作ってみました。

Issue 41165: [Python 3.10] Remove APIs deprecated since Python 3.3 - Python tracker

新しいAPIを提案するよりもずっとハードルが低いはずなので、他の削除プルリクエストを参考にしてお掃除に参加してみませんか?

DeprecatedなAPIを削除する手順は次の通りです。

  1. (初めてプルリクエストを作る場合) CLA にサインする。
  2. ドキュメントでdeprecatedになったタイミングと、DeprecationWarningを出すようになったタイミングが十分(後述)に古いか確認する。
  3. 該当のAPIを削除する。該当のAPIからしか使っていなかったprivateメソッド等も同時に削除すること。
  4. テストも削除する
  5. NEWSエントリとwhat's newエントリを書く
  6. プルリクエストを送る

Pythonの最低deprecation期間は2バージョンですが、消すのを先延ばしにするデメリットが大きくない場合や古くから存在するAPIについては、それよりも長めのdeprecation期間があったほうがいいです。

既に Issue のコメントではPython 3.3時点でdeprecateされたものをリストアップしていますが、もう少し新しいdeprecationを探す場合も3.6までにdeprecationされてるものを選んだ方が「まだ消さないで」と言われるリスクは低いと思います。

PEP 623: Remove wstr from Unicode について

今週新しいPEPを作りました。 www.python.org

背景

Python 3.3からUnicodeの内部表現が変わり、文字列に含まれる最大のコードポイントから1byte(ASCII or latin1), 2byte (UCS2), 4byte (UCS4)を選ぶようになっています。 (PEP 393 Flexible Unicode Representation)

それまでのPython 2 やPython 3.2までは、Unicodeの内部表現はUTF-16UTF-32コンパイル時に決定されていました。(narrow build, wide build と呼ばれていました) この時に一文字を表すC言語の型を Py_UNICODE として、UTF-16なら16bit、UTF-32なら32bitの符号なし整数型が使われていました。

昔の内部表現を Py_UNICODE* 型で取得するAPI (PyUnicode_AsUnicodeなど) や、文字列を作成するときに先に Py_UNICODE の長さを指定してアロケートするAPI (PyUnicode_FromUnicode(NULL, length)) などを動かすために、今の実装は typedef wchar_t Py_UNICODE した上でUniocodeオブジェクトの内部に wchar_t *wstr というメンバーを持っています。

これにより、小さいASCII文字列でも64bit環境では wstr のために8バイトを消費しています。非ASCII文字列ではwchar_t (UTF-16UTF-32)にエンコードしたときの長さのために ssize_t wstr_length というメンバーもあり、合計で16バイトを消費しています。

言うまでもなく文字列(Unicode)オブジェクトはPythonで最も大量にインスタンスが生成される型の一つなので、ほとんどの文字列がASCIIだとしても1インスタンスあたり8バイトの消費はそろそろ削りたいです。

削除に向けて

残念ながらいくつかのAPIがドキュメントではDeprecatedと書かれているものの、CコンパイラーのWarningを出すようにはなっていなかったので、すぐには消せません。 実際のところ、特にWindows関連のライブラリで引数を wchar_t* で受け取るのが便利だったので、標準ライブラリの内部でもまだまだ使われてしまっていました。

コンパイラーのWarningを抑止するマクロを併用することで、これらの内部利用されているけれど消したいAPIにWarningをつけ、これは Python 3.9 にバックポートしました。 ただ、引数を解析する関数 (PyArg_ParseTupleなど) が wchar_t* を使ってる部分は関数自体をWarning対象にはできないので、実行時にPythonのDeprecationWarningを出す必要があり、これは現在ベータになっているPython 3.9にはバックポートできません。 wstr を利用している全てのAPIや動作のDeprecationができるのが3.10になります。

PEP 623では、通常のDeprecationプロセスの最短である Python 3.12でwstrを削除することを提案しています。

影響度

このプランが現実的かどうか調べるために、PyPIのダウンロード数トップ4000のパッケージから、ソースパッケージ (.tar.gzなど) を提供している物を全てダウンロードし、これらのAPIの利用状況を見てみました。

一番多かったのがCythonが生成したコードが空文字列を作るために PyUnicode_FromUnicode(NULL, 0) を使っているもので、これはもう直してもらいました。また同じくCythonがもっとレアな条件で PyUnicode_FromUnicode を使っているのも見つけ、これも報告してあります。次のCython (0.29.21) では直っていると思うので、あとはいろいろなプロジェクトが新しいCythonを使って生成したコードをリリースするのを待てばほとんどが解消されるはずです。

他に多かったのが、(Cythonが生成したコードを含めて)Python 2に対応するために #ifdef で区切ったコードで、Python 3では使われないものです。

Cython生成コード意外で問題になるコードを含むプロジェクトは多分20前後で、ほとんどは簡単なものだったので既にプルリクエストを出したり報告して修正してもらったりしています。なのでこの変更はそれほど大きい breaking change にはならないと考えています。

バグがあっても接触確認アプリをインストールしてほしい理由

3行サマリー:

  • アプリではなくOSが接触履歴を取っている
  • 今のアプリはOSの接触履歴をONにするだけ。バグがあっても使わなければ問題ない
  • (特に東京では)今週の接触履歴が今後役に立つ可能性がある

とうとう接触確認アプリが公開されました。これで今までよりも圧倒的に効率的に、陽性者の接触者に検査を受けてもらうことができるようになるかもしれません。ワクチンが開発されるまでの間、コロナと戦うための最大の武器になるかもしれません。

www.mhlw.go.jp

しかし、Bluetooth が有効になってないと起動しない、利用規約に同意しないでアプリを終了しても同意したことになってる、などのリリース前の準備が明らかに不足してるであろう問題が報告され、炎上しています。

大前提として、これらのバグの責任はもちろんリリースした厚生労働省とその委託先の会社、そしてリリースを急がせた政府にあり、ベースとなったOSSの開発者には一切責任はありません。

ただ、普通ならリリースするべき状態ではないアプリだったとしても、特に外で人と接する機会のある人には早めにインストールして欲しいと思っています。

今回の接触確認アプリは、消費電力やプライバシーのことを考えつつ、GoogleAppleAndroidiOSに実装した接触履歴の機能を利用しています。*1 この機能を有効にするには、政府公認のアプリをインストールする必要があります。そのためにアプリをインストールしてほしいのです。

一度接触確認アプリを起動してOSの機能を有効にしたら、その後はもうアプリを起動する必要はありません。アプリではなくOSが接触履歴を取ってくれています。OSの機能さえ有効にすれば、今はアプリは使わなくても良いのです。 *2 私はリリース直後にインストールしましたが、このアプリはなんの権限も要求せず、バックグラウンドの動作時間が0で、通信量も電池消費も0です。全く動かさなくていいアプリのバグを気にする必要があるでしょうか?

将来アプリの新しいバージョンがリリースされて、バグが修正され、陽性者報告が動き始めたときに、接触履歴がOSにあれば陽性者との接触を教えてくれたり、自分が陽性になったときに接触した人にそれを(なるべくプライバシーに配慮した形で)伝えられるのです。今日接触履歴を取り始めないと、後でだれかの陽性がわかったときに今日の接触者を探すのが困難になるのです。

「アプリにバグがあったらインストールが敬遠されて逆効果じゃないか!」という意見は正論ではあるのですが、声高に問題を叫ぶことも同じくインストールを敬遠する人を増やします。 攻撃的な批判は、アプリのバグと同じくらい悪い効果があるのです。

攻撃的な批判ではなく、問題の回避方法とか、「起動できない人はアップデートを待ってね」と、インストールするモチベーションをなるべく下げない言い方で意見表明して欲しいと思います。

現在東京は一日に30人前後の新規陽性者が確認されている状況です。新型コロナは潜伏期間が長いので、今一日100人、200人が新規感染していて、それが2週間後に新規陽性者数として現れてくるかもしれないのです。そのときに陽性者報告が動き始めたとして、今週からの接触履歴があるのと無いのとでは大きな差になりえます。

このように、本来あと1週間か2週間かけて準備するべきリリースを前倒ししたことには十分な意義があったと思います。(それを説明する責任は政府にあると思いますが)

*1:すでに接触確認アプリを使ってる国で消費電力やプライバシーが問題になっているのは、GoogleAppleの実装を待たずにアプリで接触履歴を取っているからです

*2:アプリの役割である陽性者報告がまだ動いてないので、そもそもOSの機能を有効にする以外の使いみちはまだありません

ISUCON9決勝参加記 (チーム名: ようするにメガネが大好きです)

ISUCON9決勝に参加して fail しました。原因は自分の書いたバグで大分凹んでいます。ピークスコアは14991点でした。

やったこと

自分がやった分だけ。大したことはやれていません。

  • pprof, stackdriver trace の導入
  • Docker compose があったのでローカル開発できるか試してすぐやめた
  • reservation の N+1 解消 ref
  • distance_fare_master のオンメモリ化 ref
  • reservation テーブルの arrival, depature カラムに駅名ではなくIDを入れるようにした。これで getAvailableSeats で station との join が消せた。 ref

失敗したこと

fail の原因になったバグはこんなコードです。

   for {
        _, err := dbx.Exec("select 42")
        if err != nil {
            log.Println(err)
            time.Sleep(time.Second)
        }
        break
    }

接続ができなくても Sleep したあと continue してないですね。。。。これでDBより先にアプリが起動したら、 master データをDBから持ってくるコードが動かずに fail してしまいました。

cancel を bulk 化する前にいったん遅延するようにしてみたところエラーが起こり revert しました。しかしこれはどうやら別の原因だったようです。

/initialize で負荷を指定できることに気づいておらず(大ポカ)、17:20頃に教えてもらって試したら cancel がたまりすぎてエラーに。慌てて bulk を実装始めるもヌルポエラーを出してしまい、修正できないまま再起動試験に。

あと、 makki_d が train_master, seat_master をオンメモリ化してくれている間に座席のロックの粒度を下げたり、席が空いているかどうかを高速にチェックできるようにする方法を考えようとしていたのですが、全然頭がクリアに働かず頭の中をぐるぐるするだけで何もできませんでした。この時間にキャンセルの bulk 化とか落ち着いてレギュレーション読むとかできればもっと色々できたはず。

感想と反省

意味不明の print だらけでログがぐちゃぐちゃになるクソコード、プライマリキーが存在せずに駅名等が全て文字列で入っているテーブル、とにかく問題外な初期実装でした。(褒め言葉)

家庭の問題があってメンタルも睡眠時間も不足気味で実力を発揮できないばかりか致命的なミスをしてしまい本当に悔しいやら情けないやら。来年もできれば同じチームで参加して挽回したいです。

ISUCON9予選参加記 (5位通過)

mapk0y (インフラ), makki_d (アプリ) とともに「ようするにメガネが大好きです」というチーム名で参加し、2日間を通して5位のスコアで通過しました。

選択言語は Go です。ソースコードはこちらで公開しています。 GitHub - methane/isu9q

POST /buy 初期対策

CPUプロファイルを見ると login の bcrypt が重いのはすぐに目に付きます。過去にもあったのでsha1など軽いハッシュへの置き換えも考えましたが、面白くないのでこれはしなくても攻略できるようになっているはずだと出題者を信じて無視します。 最終的に login は残りの2台に分散するだけで一切手を加えずに乗り切りました。

アクセスログを見ると明らかに POST /buy が重く、 campaign を増やしたときのマッコネ (MySQL の Too many connections) やベンチマークタイムアウトエラーの主因にもなっていました。とりあえず最初はシンプルにGoの段階で排他してやります。

+var itemMutex sync.Mutex
+
+func itemLock() func() {
+   itemMutex.Lock()
+   return itemMutex.Unlock
+}
+
 func postBuy(w http.ResponseWriter, r *http.Request) {

...


+   defer itemLock()()
+
    tx := dbx.MustBegin()
 
    targetItem := Item{}
    err = tx.Get(&targetItem, "SELECT * FROM `items` WHERE `id` = ? FOR UPDATE", rb.ItemID)

外部APIエラーとの戦い

しかし外部APIサーバーが 502 Bad Gateway を大量に返すようになってしまって fail が続きます。

f:id:methane:20190910094451p:plain
502-bad-gateway

答えられないということは、障害ではなくて仕様なのでしょう。(追記: 障害だったようです。なので私達のチームはこのエラーのために多くの時間を浪費することに。) ここからAPIサーバーとの戦いが始まります。

マニュアルを読んでもレートリミットやバッチ化呼び出しなどの仕様はなく、 tcpdump を使って確認してもヘッダは削られまくっててやはりレートリミットに関するヘッダなどのヒントはありません。

この API は購入と配送に関するもので、1取引あたりの最低呼び出し回数は決まってます。とりあえず GET /transaction のなかからの呼び出しを削除してその時点の暫定1位はゲットしたものの、その後はずっとAPIの呼び出し方を工夫していました。

mapk0y が chocon という API 呼び出し用の proxy サーバーを入れてくれ、 makki_d がその対応や分散などをしてくれました。

呼び出しの並列度を下げても、すこし間隔を入れてもエラーが出ます。 Bad Gateway 以外に bad IP もあるみたいなので、API呼び出しを3台から分散して行うようにしてみたのですが効果は微妙です。

マニュアルにベンチマーク中以外に使える開発用APIサーバーが載っていたのでそこに curl でアクセスしてみて、使い方がわかったところでそれを雑に while : で回したらエラーが出ないことに気づきました。それで chocon の keep alive を切ってもらったのですが、残念ながらエラーはまだ出たので最終的に makki_d にリトライを入れてもらってゴリ押ししました。

結局502エラーの数が安定しないまま、どの対策がどれくらい効果があるのかを確認できないままでした。ベンチを回すばかりでなく、もうちょっと直接APIサーバーを色んな方法でアクセスして bad gateway や bad ip エラーの発生条件を調べられなかったのが反省点です。(ブラックボックスリバースエンジニアリング、好きな人は大好きだけど、そうじゃない人は逃げがち)

その他のチューニング

上で紹介した雑なロックは次のように書き換えて別々の商品は並行して買えるようにしました。

var itemMutex []sync.Mutex = make([]sync.Mutex, 100)

func itemLock(i int) func() {
    n := i % 100
    itemMutex[n].Lock()
    return itemMutex[n].Unlock
}
...
    defer itemLock(int(rb.ItemID))()

これでも /buy のタイムアウトが発生します。商品が売れたあとに、API呼び出しを含む長いロックを待ってた人が1人ずつ売れてしまったことを確認するようになっていたので、売れた商品をキャッシュするようにするとタイムアウトが解決しました。

そもそも売上がスコアに直結するのに同じ商品に客が集まり過ぎなので、これをバラせないかなと思って新着商品の順番をページ内でシャッフルしてみたところベンチマーカーに怒られたので諦めてしまいました。あとから考えるとここがスコアに直結するので諦めたのは早計で、 /buy でロック中の商品(APIエラーで売れない可能性がある) を sold 扱いで表示するとか、隠すとか、もうちょっと粘るべきだったと思います。

この辺は実際のアプリ開発なら自分から提案できるんですが、あくまでもブラックボックスのベンチマーカーがルールのISUCONでは提案ができないので、ベンチマーカーが何を許すかの試行錯誤を面倒がってしまいます。。。チューニングがしたいんであってブラックボックスリバースエンジニアリングがしたいんじゃないんですよね。。。

他には、 items テーブルの seller_id, buyer_id, created_at カラムにインデックスを張ったり、設定をいじったりしていました。アプリ側で一番大きい変更 (user の N+1 解決) は makki_d がやってくれました。

感想

去年の予選は上位スコアを出しつつも重いAPIの対策をおろそかにしていたために終盤スコアが乱高下し結果敗退しました。

その反省から、今年は12時台に 502 エラーが大量発生したあとは、頑張ってチューニングしてエラーで fail するという結末を避けるために穏当なチューニングだけで手堅く勧め、エラー率の低い(スコアが安定する)状態で勝ち進むことができました。

一方でAPIエラーの発生条件の解析や、商品リストをどう改造するとベンチマーカーがエラーを出すかの解析など、役割分担的に僕がするべきだったところの解析がちゃんとできていなかったのが悔やまれます。これを突破しないと実力での優勝はできないので、決勝では面倒臭がらずもっと覚悟と気合を入れていきます。

設問とインフラは良かったと思います。特に Go の初期実装が (Sinatra風の無名関数を直接ハンドラに登録するやりかたでなく) ちゃんとAPIごとに名前のある関数を用意しているのでスタックトレースやプロファイルも読みやすかったですし、ベンチマーク待ちも(17:20以降は完全に手を触れなかったのもあり) ほぼ感じませんでした。

一日目の Twitter を見てトラブルを覚悟していたのですが、初回起動もすぐ終わり、インスタンス作り直しが必要なミスもしなかったのでとても快適に競技に集中することができました。ありがとうございます。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。