nekop's blog

OpenShift / JBoss / WildFly / Infinispanの中の人 http://twitter.com/nekop

Java EE環境における非同期プログラミング

Java EE環境では基本的にスレッドの生成は許されていません。この制限はEJB仕様書に記述されており、ブループリントなど他のドキュメントにも記載されています。これらの制限はかなり古い時代に考えうる最大の制限を記述したものであり、「ファイルにアクセスしてはならない」など今となってはあまり現実的ではない記述も多くなっています。しかしながら、「スレッドを生成してはならない」というのは依然多くのコンテナに適用される現在も有効な制限であり、実際にスレッドの生成などを行うと誤動作するケースがあります。今回は、なぜこのスレッドの制限があるのか、現実的にどうすれば良いのか解説します。

コンテナは様々なものをスレッドに結びつけて管理しています。様々なものというのは例えばセキュリティ、トランザクション、データソースなどのコンテナリソースです。コードのほうがイメージしやすいでしょうから、以下に擬似コードを挙げます。

try {
  // スレッドローカルにコンテナ管理を行うためのオブジェクトを設定
  // このオブジェクトはtask.execute()の中でコンテナリソースが利用される場合に必要
  threadLocal.set(new ContainerManagementContext()); 

  // アプリケーションコードの実行
  task = taskList.get();
  task.execute();

} finally {
  // リソースの開放とか
  // コンテナ管理オブジェクト削除
  threadLocal.remove();
}

Java EEコンテナはこのコードのようにスレッド単位で(主にThreadLocalを利用して)実行環境を管理することによって煩雑な境界(バウンダリ)に関するいろいろな問題を解決しています。基本的には一つのアプリケーションメソッドでトランザクションの開始/終了、リソースの取得/開放が必ず完結するという設計です。コンテナ管理トランザクションはアプリケーションメソッドを呼び出す前にはコンテナにより開始されており、アプリケーションメソッドを抜けたときには必ずコンテナによってcommit/rollbackされます。また、アプリケーションがJDBCコネクションをオープンしたけど正しくクローズしていないくてリークしてしまった、というようなことを防いで警告してくれたりします。便利ですね。これを実現するためにアプリケーションが実行されてる裏側ではトランザクションやセキュリティやリソース管理に関する様々なオブジェクトやステータスなどがThreadLocalに設定されているわけです。

さて、上に挙げたコードではアプリケーションは常に正しくContainerManagementContextが紐付けられたスレッド上で動作することになります。アプリケーションがスレッドを作成しない限り、という条件付きで。

アプリケーションがアプリケーションコードからスレッドを生成してしまうと、このスレッド単位の管理を破壊します。見て分かる通り、task.execute()の中でアプリケーションが新しくスレッドを作成した場合、新しいスレッド上ではContainerManagementContextが正しく初期化されていない状態となります。結果としてそのままコンテナリソースにアクセスしたりすると、NullPointerExceptionやIllegalStateExceptionやよくわからないエラーやWarningログが発生することになってしまいます。やっかいなのがInheritableThreadLocalを使っている場合で、この場合ContainerManagementContextが複数スレッドで共有されちゃったりして並行性の問題やセキュリティ侵害などおもしろおかしいことが起こります。

Java EEでは非同期プログラミングのサポートとしてMDBが用意されています。抽象化されたメッセージオブジェクトを介してデータを受け渡して実行されるMDBを利用することにより、セキュリティ、トランザクション、リソース、データの可視性などの境界をシンプルかつキレイに保ってくれ、さらにアプリケーションの設計もある程度矯正されてキレイになるというオマケつきです。

また、Java EE 6 Full Profileに含まれるEJB 3.1では@Asynchronous EJBがサポートされており、コンテナ管理の別スレッドでEJBの呼び出しを非同期で行って結果をFutureで受け取る、という機能が利用できます。この機能はEJB 3.1 Liteには収録されていないのでJava EE 6 Web Profile仕様では利用できないのですが、EJB 3.2 Liteには収録されることが決まっているのでJava EE 7からはWeb Profileでも利用できるようになります。

過去にはJSR-237 Work Manager for Application ServersというJava EEコンテナでアプリケーションが利用できる非同期APIの仕様が策定されかけましたが、この仕様はWithdrawn、つまり頓挫しました。JSR-237 WorkManager実装は今では単なるオレオレ非同期ライブラリの一つでしかありません。

このスレッドの制限はEJB仕様に記述されてはいますが、これはServletなど多くの他のコンポーネントにも当てはまります。Servletではコンテナ管理トランザクションはありませんが、コンテナ管理のセキュリティやリソースは依然利用でき、それらはEJBと同様にスレッドベースの管理が行われているからです。

かといってJava EEコンテナ上でまったくスレッドを起動できない、というのは不便ですね。ベンダ依存のAPIも使いたくありません。というわけで以上を踏まえて妥協点となるある程度安全にスレッドや非同期APIを利用する方法を探ってみましょう。

まず、できるだけ安全にスレッドの生成ができるポイントを探します。ServletEJBの実行スレッドは制限の影響を強く受けるスレッドなので確実にダメです。EJB 3.1では@Singleton @StartupというEJBを作成することもできますが、EJBにしてしまうとEJBメソッド呼び出しとなってしまってトランザクション管理などが実行されることになりますから同様の理由でスレッドの作成などの行儀の悪いコードを書くのには適しません。

というわけでJava EE標準の定番初期化ポイント、ServletContextListenerを利用します。これならEJBではない、かつアプリケーションの初期化中に呼ばれる部分なのでセキュリティ管理やトランザクション管理の外である可能性が高い。ベストです。

ここでServletContextListenerでExecutorを作ってアプリケーションから使えるようにしましょう、というのではダメです。Executorを利用した場合、タスクを追加した呼び出し元スレッドが新規スレッドを作成することになります。これではServletContextListenerは実際にはスレッドを作成しておらず、上で挙げたコンテナのスレッド管理を破壊するダメパターンの実装そのものになってしまいます。

というわけでServletContextListenerを利用した上で、アプリケーションスレッドからはスレッド生成が発生しないように、ここはワークキューパターンで書かないといけないよ、ここでスレッドを事前生成しないといけないよ、というお話です。理解できる人はそのように実装してください。理解できない人は単にバグを生んでしまうので理解できるまでやめたほうがいいです。また、アプリケーションのアンデプロイ時にスレッドやThreadLocal、クラスローダーリークを発生させないよう終了処理もきちんと実装する必要もあります。

実装してみたら恐らく気付くと思いますが、ここまでやってしまうとその実装は何かに似てきます。そうです、MDBですね。というわけでJava EEで非同期プログラミングをする場合にはMDBを使いましょう、という振り出しに戻るオチでした。

勝手にスレッドを作ってしまうライブラリを使いたい?実行スレッドを考慮していないライブラリ側の制限なのでライブラリを直しましょう。無理な場合は変なエラーが発生しないように神様にお祈りしながら利用してください。

未来の話をしましょう。2003年に提出されたまま進展のなかったJSR-236 Concurrency Utilities for Java EEJava EE 7採用に向けて復活したところです。まだ再スタートしたばかりでどのようなAPIになるか現時点ではわかりませんが、MDBよりもう少し細かい粒度のコンカレントプログラミングモデルがサポートされるのではないかと思います。メーリングリストなども新しく作成されて公開されているので興味があれば覗いてみてください。

まとめ

  • Java EE環境ではスレッド単位で管理されているもののがいっぱいあるためにスレッド作成が禁止されていることを理解しましょう
  • 非同期処理にはMDBか@Asynchronous EJB使いましょう
  • (動く保証はないですが正常に動作する確率を最大化するために)どうしても別スレッドをスタートしたいなら必ずServletContextListenerが呼ばれたスレッド上から別スレッドをスタートしましょう