streamlitの仕組み調査

pythonで手軽にWebアプリケーションを作れるフレームワークstreamlitの仕組みについて調べてみました。（誤解がありましたら、どうぞご指摘ください。）
周辺の業務で使うことが結構増えて来たため、何か性能で問題になった場合や拡張が必要な場合にも対応出来るようにと調べてまとめました。

githubのコードと公式のドキュメントを参考に調査しています。
https://github.com/streamlit/streamlit

### アーキテクチャ
streamlit は Python([tornado](https://www.tornadoweb.org/en/stable/index.html))+React(typescript)で実装されています。
streamlitでは、次の2つのパートがあります。

1. web server : streamlitのpythonアプリケーションコードをブラウザに表示するためのフロントエンドのHTML+jsをインスタンス化して通信するために使用します。例えば、アプリケーションでcsvのデータを読み込んで、ブラウザには折れ線グラフのHTML+jsを送って、レンダリングさせます。
2. フロントエンド : streamlitが自動生成してくるため、通常の開発者はフロントエンドのコードを書くことはないです。browserで表示するためのアプリケーションコードは、HTMLやJavaScriptおよびReactやTypeScriptなどから作れて、iframeタグを介してstreamlitのコンポーネントがレンダリングされます。streamlitはいわゆるファーストビューで軽いメニューなどのコンポーネントを先に出しつつ、重いコンポーネントは遅延ロードされて、次々に表示されていきます。

### 全体シーケンス
browserとweb serverとのざっくりとした流れは以下のようになっています。

```mermaid
sequenceDiagram
    web server->>web server: server initialize
    web server-->>web server: polling_path_watcher(app.py, others custom codes)
    browser->>web server: request page
    web server->>web server: load custom page
    web server-->>browser: first view rendering (static react.js+html)
    browser-->>browser: load react.js+html
    web server->>web server: load component
    web server-->>browser: render
    web server->>web server: load component
    web server->>browser: rendering complete
```

サーバが初期化されるとカスタムコードのパスをリストアップして、コードの変更を検出するwatchdogに登録されます。(コードが変更されると再起動せずに動的に読み込まれる仕組みがあり、止めることも出来るがデフォルトでは有効になっています。プロダクションコードでは無効にした方が良いかと思います。)
次にブラウザからリクエストが来ると、対象ページのpythonコードがロードされ静的な構文チェックが行われて問題なければ、静的なhtml+jsがサーバサイドで作られてブラウザに送られます。次に対象ページで使われているコンポーネントのコードがブラウザに送られて徐々にレンダリングされます。この時、画面右上にはRunningと表示されます。

次にコンポーネントについて細かくみていきます。
公式ドキュメントのコンポーネントの作り方から紐解くことが出来ます。
https://docs.streamlit.io/library/components/components-api

### コンポーネントについて
streamlitコンポーネントはiframeにレンダリングされ独自のページになるため、React, Vue, TypeScriptなど好きなWeb技術を使用出来ます。

### コンポーネントの種類

* 静的コンポーネント　　: Pythonによって制御され一度だけレンダリングされる
* 双方向コンポーネント : PythonとJavaScriptの間で通信できる双方向の動的なコンポーネント

### 静的コンポーネント

標準で備わっているmarkdownやLaTeXなどの文字列をフォーマットしてHTMLに出力するst.writeなどのコンポーネントが該当します。

Streamlitコンポーネントを作成する目的が、HTMLの表示またはグラフのレンダリングのみである場合、2つのメソッドcomponents.html（）とcomponents.iframe（）のみで実装できます。

#### HTML文字列をレンダリングする
st.text、st.markdown、st.writeを使用すると、Streamlitアプリにテキストを簡単に書き込むことができますが、HTMLのカスタム部分を実装したい場合もあります。
同様に、Streamlitは多くのグラフ作成ライブラリをネイティブにサポートしていますが、新しいグラフ作成ライブラリ用に特定のHTML/JavaScriptテンプレートを実装することもできます。

### 双方向コンポーネント

#### Reactコンポーネントのシーケンス
ざっくりとした処理の流れのイメージ図がこちら

```mermaid
sequenceDiagram
    browser->>browser: Streamlit.setComponentValue(xxx)
    browser->>web server: simple get/post
    web server->>web server: my_component(xxx)
    web server-->>browser: response data(as in sync)
    Note left of browser: eventListener
    browser-->>browser: setFrameHeight()
    browser-->>browser: exportdefaultwithStreamlitConnection(MyComponent)
```

Streamlit.setComponentValue（new_value）を呼び出すと、その新しい値がStreamlitに送信され、StreamlitがPythonスクリプトを上から下に再実行します。スクリプトが再実行されると、my_component（...）を呼び出すと新しい値が返されます。

コードフローの観点からは、フロントエンドと同期処理してデータを送信しているように見えます。Pythonは引数をJavaScriptに送信し、JavaScriptは値をPythonに返します。これらはすべて1回の関数呼び出しで行われます。しかし実際には、これはすべて非同期で行われており、手先の早業を実現するのはPythonスクリプトの再実行です。

Streamlit.setFrameHeight（）を使用して、コンポーネントの高さを制御します。デフォルトでは、Reactテンプレートはこれを自動的に呼び出します（StreamlitComponentBase.componentDidUpdate（）を参照）。より詳細な制御が必要な場合は、この動作をオーバーライドできます。
最後の行にちょっとした魔法があります。exportdefaultwithStreamlitConnection（MyComponent）-これはStreamlitでハンドシェイクを行い、双方向のデータ通信のメカニズムを設定します。


- フロントエンド とのデータ送受信
  フロントエンドと同期処理してデータを送受信している箇所について細かくコードを眺めると次のように実装されています。ブラウザ からイベントが発行されると次のシーケンスでデータが送信され、バックエンドのコードが実行されて、再レンダリングされます。
  以下はシンプルなst.writeが呼ばれた場合の全体のシーケンスです。

```mermaid
sequenceDiagram
    browser->>browser: ForwardCache()
    browser->>web server: ws: backMsg.rerun_script
    web server->>web server: request_rerun(backMsg.rerun_script)
    web server->>script thread: rerun_script(script, session_info)
    script thread->>script thread: st.write()
    script thread->>component: elements.write()
    component->>component: delta_generator._enqueue(msg) 
    Note right of component: Create the Delta protobuf messages
    component->>component: ctx.enqueue(msg)
    script thread->>web server: _on_script_finished(ctx)
    web server->>web server: session_info.session.flush_browser_queue()
    web server-->>browser: ws:flush all tabs forwardMsg
    browser-->>browser: handleScriptFinished(ForwardMsg.scriptFinished)
```

次のタイプのメッセージがバックエンドに送られます。

  | back message type |
  | ----------------- |
  | rerun_script      |
  | load_git_info     |
  | clear_cache       |
  | set_run_on_save   |
  | stop_script       |
  | close_connection  |

複数タブでセッション情報を共有している場合には、バックエンドから複数のタブに対して更新が走ります。

:::note info
2022/06/15更新
[Does streamlit is running on a single-threaded development server by default or not?](https://discuss.streamlit.io/t/does-streamlit-is-running-on-a-single-threaded-development-server-by-default-or-not/9898)

ユーザー専用のスレッドを起動し、app.pyスクリプトを実行するとあり、上記のscript threadを指しているはずです。
ユーザーがインタラクティブなボタンを押すなどの操作で、そのスクリプトが再実行されます。再実行のたびに、スレッドが再度スピンアップ・スピンダウンして実行されます。つまり、streamlit自体はマルチスレッドであり、多くの強力なコアを備えたマシンで実行することでメリットが得られる。

スクリプトを迅速に効率的に実行することが重要で、[streamlit組み込みのキャッシュユーティリティ](https://docs.streamlit.io/library/advanced-features/caching)が最適なツールとなる。これは、不要な再実行をしないことでサーバの負荷を下げる効果になる。

2022/06/16更新

ユーザーにスレッドが割り当たるとの事で、初期表示では複数コンポーネントがある場合はフロントエンド との送受信とサーバでの処理が継続的に発生すると思います。また、ユーザー数がvcpuの数を超えてくるとcpuの取り合いになり、コンテキストスイッチのオーバーヘッドが出てくると思います。
script threadの処理はscript_runner.pyで書かれているのですが、threadingが使われており、これはPythonのGIL制約によってCPU boundな処理に対しては効果を発揮しません。初期表示の同時アクセスのようなCPU boundな場合にはマルチコアCPUの効果は薄いかもしれません。I/O boundは効果を発揮するため、その後のボタンイベント等でのデータ読み書き・ネットワークI/Oが多い場合には効果があります。小規模なユーザが頻繁にデータアクセスするユースケースには向いているアーキテクチャと言えるかもしれません。concurrent.featureやmultiprocessingに書き換えた場合の効果を見てみたいところです。
[Python のプログラムを並列処理で高速化する](https://tech.morikatron.ai/entry/2020/03/13/100000)

:::

:::note info
ちなみに [tornado multiprocess](https://www.tornadoweb.org/en/stable/guide/running.html#processes-and-ports)　tornadoのマルチプロセスモードは使われていませんでした。
server.py等の実装を見ると
```http_server.listen(port, address)``` とだけあり、```http_server.start(0)```で起動していません。
:::


#### 初期表示後のデータ交換

初期表示後のブラウザとweb serverの間の通信にはwebsocketが使われ、データ交換にはスキーマ定義に[Protocol Buffers(proto3)](https://developers.google.com/protocol-buffers/)が使われる。データフォーマットは、基本的なデータタイプはJSONフォーマットで、シリアライズされます。特に配列データはデフォルトでは圧縮して高速にシリアライズする [Apache Arrow](https://arrow.apache.org/) が使われます。

----

#### テンプレート
テンプレートが用意されています。
https://github.com/streamlit/component-template/

https://docs.streamlit.io/library/components/components-api#create-a-bi-directional-component

![](https://storage.googleapis.com/zenn-user-upload/9fdb1b48b929-20220613.png)


テンプレートのサンプルアプリは、双方向通信がどのように実装されているかを示しています。 
Streamlitコンポーネントはボタン（Python→JavaScript）を表示し、エンドユーザーはボタンをクリックできます。
ボタンがクリックされるたびに、JavaScriptフロントエンドはカウンター値をインクリメントしてPythonに戻し（JavaScript→Python）、Streamlitによって表示されます（Python→JavaScript）