CodeGym /コース /JAVA 25 SELF /非同期ファイル操作におけるエラー解説

非同期ファイル操作におけるエラー解説

JAVA 25 SELF
レベル 56 , レッスン 4
使用可能

1. バッファでのミス: ByteBuffer、position と limit

非同期の読み書きメソッドは ByteBuffer を扱います。通常の配列と異なり、バッファには「内部カーソル」— positionlimit — があり、どのバイトが読み書きされるかを決めます。これらのプロパティを誤って扱うと、期待と異なる結果になったり、ロジック自体が壊れたりします。

コードではどう見えるか?

誤った使用例:

ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // あっ! バッファから直ちに文字列を読み出そうとしている:
        String str = new String(buf.array()); // これは間違いです!
        // ...
    }
    // ...
});

何が問題か?

  • 読み取り後、バッファは「書き込み」モードのままです: position は読み取られたデータの末尾、limit はバッファ容量を指します。この状態で直ちに読み出すと、実際には 10 バイトしか読んでいなくても(例)、1024 バイトすべての「ゴミ」を得てしまいます。
  • buf.array() は内部配列全体を返し、読み取り済みの部分だけではありません。さらに、ダイレクトバッファ(ByteBuffer.allocateDirect で確保)では array()UnsupportedOperationException を投げます。

正しいやり方は?

バッファからデータを読む前に buffer.flip() を呼び、バッファを「読み取り」モードに切り替えます:

public void completed(Integer result, ByteBuffer buf) {
    buf.flip(); // ここで position = 0、limit = 読み取られたバイト数
    String str = StandardCharsets.UTF_8.decode(buf).toString();
    // ... 文字列の処理
}

バッファの再利用

次の操作でもバッファを再利用したい場合は、データ処理後に buffer.clear() または buffer.compact() を忘れずに呼びます:

  • clear() — 境界を完全にリセットします: position=0limit=capacity()。古いデータは破棄対象と見なされます。
  • compact() — まだ読んでいないバイトを先頭に詰めて、追記の準備をします。

注意: result-1 の場合は EOF(ファイル終端)に達しています — 追加の処理は不要です。

2. 並行アクセス: レースと不整合

AsynchronousFileChannel は複数の操作を並行に開始できます。しかし制御しないままにすると、破損したデータやクラッシュを招きやすくなります。

問題 1: 同一バッファへの同時読み取り

// 同じバッファに対する 2 つの同時読み取り
channel.read(buffer, 0, buffer, handler1);
channel.read(buffer, 1024, buffer, handler2);

どちらの読み取りも同じバッファに書き込みます。ほぼ同時に完了すると、バッファ内容は予測不能になります。

問題 2: 同一ファイルへの同時書き込み

2 つのスレッドが同じファイル領域に同時に書き込むと、どちらの操作が先に終わるかで結果が変わります。これは典型的なレース(race condition)で、データ破損につながります。

回避方法

  • 各非同期操作ごとに専用のバッファ(ByteBuffer)を使い、スレッドが互いのメモリに干渉しないようにします。
  • 同一のファイル範囲への並行書き込みは避け、オフセットを分離するか、アクセスを同期化します。
  • 厳密な順序が重要な場合は、前の操作完了後に次の操作を開始します — 例えば CompletionHandlercompleted(...) から起動します。

3. リソースリーク: チャネルを閉じ忘れる

非同期チャネルはシステムリソースです。これを閉じない(channel.close() を呼ばない)と、ファイルがシステム上で「使用中」のままになり、メモリリークが発生する可能性があり、Windows では他のプログラムからのファイルアクセスがブロックされることもあります。

典型的なミス:

AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, ...);
// ... 操作を開始
// すべての操作完了後に channel.close() を呼ぶのを忘れた!

正しいやり方は?

try-with-resources を使い、ブロックを抜ける前に必ず全操作の完了を待ちます:

CountDownLatch latch = new CountDownLatch(1);

try (AsynchronousFileChannel channel =
         AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {

    ByteBuffer buf = ByteBuffer.allocate(4096);
    channel.read(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
        @Override public void completed(Integer r, ByteBuffer b) {
            // 処理...
            latch.countDown();
        }
        @Override public void failed(Throwable ex, ByteBuffer b) {
            ex.printStackTrace();
            latch.countDown();
        }
    });

    latch.await(); // 非同期処理の完了を待つ
}
// クローズは自動で行われる

4. 例外処理: CompletionHandler でのエラー無視

非同期コードではエラーはメインスレッドに「飛んで」きません — CompletionHandlerfailed(...) メソッドに渡されます。これを実装しない、または空のままにすると、エラーは消えてしまい、プログラムは不可解な挙動になります。

「見えない」エラーの例:

channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // ... 結果の処理
    }
    @Override
    public void failed(Throwable exc, ByteBuffer buf) {
        // あっ、空っぽ!エラーが失われた
    }
});

正しいやり方は?

@Override
public void failed(Throwable exc, ByteBuffer buf) {
    System.err.println("ファイル読み取り中のエラー: " + exc.getMessage());
    exc.printStackTrace();
    // 必要に応じて: チャネルを閉じる、メトリクスを更新、ユーザーに通知 など
}

5. Future/CompletionHandler への参照喪失

Future で非同期操作を開始したのに参照を保持し忘れると、操作をキャンセルしたり完了を待ったりできません。同様に、CompletionHandler を使っていて全操作の完了を同期しなければ、プログラムが早まって終了することがあります。

例:

channel.read(buffer, 0, buffer, handler); // handler — 無名で、どこにも保持されない
// 読み取り完了を待たずにすぐにプログラムが終了した

正しいやり方は?

  • Future<Integer> を使う場合: 参照を保持し、必要に応じて future.get() または future.cancel(true) を使用します。
  • CompletionHandler を使う場合: CountDownLatchSemaphore などの同期機構を用い、チャネル/プログラムを閉じる前に全操作の完了を適切に待ちます。

6. エンコーディングの落とし穴

テキストファイルの読み書きではエンコーディングの扱いが重要です。バイトを安易にそのまま文字列にすると、特に分割読み取り時に文字化けやデータ損失を招きます。

問題:

// ファイルを 1024 バイトずつ読み、そのまま文字列に変換
String chunk = new String(buffer.array(), "UTF-8");

UTF-8 などの多バイト文字が 2 つのバッファにまたがると(例: 1 バイトが前のバッファ末尾、残りが次の先頭)、不正な文字になったりデコードエラーになります。

正解: CharsetDecoder を使用し、読み取り間で未完の文字を保持します:

CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
        .onMalformedInput(CodingErrorAction.REPORT)
        .onUnmappableCharacter(CodingErrorAction.REPORT);

ByteBuffer byteBuf = ByteBuffer.allocate(4096);
CharBuffer charBuf = CharBuffer.allocate(4096);

// 各 completed(...) ごとに:
byteBuf.flip();
CoderResult cr = decoder.decode(byteBuf, charBuf, false); // false — これは入力終端ではない
if (cr.isError()) {
    cr.throwException();
}
byteBuf.compact();
charBuf.flip();
String text = charBuf.toString();
charBuf.clear();

// 入力がもう来ないとき:
decoder.flush(charBuf);

7. プログラムの早期終了

非同期操作は他のスレッドで実行されます。メインスレッドが先に終了すると、結果を待たずにプログラム自体が終了してしまいます。

例:

// 非同期読み取りを開始
channel.read(buffer, 0, buffer, handler);
// メインスレッドが即終了 — プログラムが終了し、操作が完了しなかった

正しいやり方は?

CountDownLatchSemaphore、もしくはデモ用途なら Thread.sleep(...) を使って、全操作の完了を待ちます:

CountDownLatch latch = new CountDownLatch(1);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override public void completed(Integer result, ByteBuffer buf) { /* ... */ latch.countDown(); }
    @Override public void failed(Throwable exc, ByteBuffer buf) { /* ... */ latch.countDown(); }
});
latch.await(); // 操作の完了を待つ

8. ExecutorService との不適切な統合

AsynchronousFileChannel ではイベント処理用に独自の ExecutorService を指定できます。プールのスレッド数が少なすぎたり 1 本だけだと、すべての操作は逐次的に実行されます。逆に大きすぎると、コンテキストスイッチのオーバーヘッドが増えます。

例:

ExecutorService executor = Executors.newSingleThreadExecutor();
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, options, executor);
// すべての非同期操作が実質的に同期的になる!

正しいやり方は?

  • 実際の負荷と同時操作数に合わせてプールサイズを調整します。
  • 多くの用途では ForkJoinPool.commonPool()Executors.newCachedThreadPool() が適しています。
  • 渡した ExecutorService が担当するのはコールバック(completed/failed)の実行であり、ディスク I/O 自体ではないことを理解しておきましょう。
1
タスク
JAVA 25 SELF, レベル 56, レッスン 4
ロック未解除
インスタントメッセージの送信: 非同期書き込みと確実なクローズ ✉️
インスタントメッセージの送信: 非同期書き込みと確実なクローズ ✉️
1
タスク
JAVA 25 SELF, レベル 56, レッスン 4
ロック未解除
システム起動の同期: 設定の読み込みの待機 ⚙️
システム起動の同期: 設定の読み込みの待機 ⚙️
1
アンケート/クイズ
ファイルの非同期操作、レベル 56、レッスン 4
使用不可
ファイルの非同期操作
ファイルの非同期操作
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION