GameAnvil은 다음과 같은 목적을 위해 비동기 처리를 지원합니다.
관점 | 설명 |
---|---|
코드 생산성 | 간결하고 짧아지는 코드비동기화 방식에 따라서는 콜백 처리나 스레드 위임 등의 처리는 모두 생략 가능 |
비동기화 방식에 따라서는 콜백 처리나 스레드 위임 등의 처리는 모두 생략 가능 | |
성능 | 드라이버나 라이브러리 수준에서 최적의 구현을 제공해 줄 경우 성능 UP |
외부 스레드 풀을 이용한 스레드 단위의 비동기 처리는 리소스 소비와 비용이 큼 | |
가상 스레드 지원 | 스레드가 아닌 가상 스레드 중심의 비동기 처리 |
Future 지원 강화 |
GameAnvil의 API도 다른 많은 비동기 방식 API들처럼 Future를 리턴하는 방식으로 동작합니다. 더욱 자유롭게 코드 흐름을 만들 수 있습니다. GameAnvil에서 Virtual Thread를 공식 지원하기 때문에, 블로킹 코드를 호출하더라도 Java 21을 지원하는 코드라면 Platform Thread 가 아닌 Virtual Thread가 블록이 됩니다.
Future<Packet> packetFuture = user.requestToNode(nodeId, myRequest1);
Future<Response> httpFuture = getHttpClient().executeRequest(myRequest2);
Packet packetResponse = packetFuture.get();
Response httpResponse = httpFuture.get(); // Java 21 에서는 Virtual Thread 만 블락
[참고]
Platform Thread : 기존 Java에서 사용한 Thread. OS 스레드 혹은 자바 스레드입니다.
Virtual Thread: Java 21에서 추가된 새로운 Thread입니다 이전 버전 GameAnvil의 Fiber 와 유사한 동작을 합니다 자세한 동작은 여기를 참고하십시오.
기존 Java 에서 많은 RDBMS 드라이버는 java.sql.DriverManager
를 사용하고 있으므로 쿼리는 블로킹입니다. 그러나 Java 21 에서는 Virtual Thread 위에서 실행 시 이러한 블로킹 쿼리를 Virtual Thread 만 정지하는 형태로 바꿔 실행하여 비동기를 활용한 향상의 이점을 누릴 수 있습니다. GameAnvil 역시 Virtual Thread 위에서 실행하여 비동기 쿼리를 통한 성능 향상이 가능합니다. 이러한 드라이버는 대표적으로 MySQL Connector/J 가 있겠습니다.
비동기 규칙을 자세하게 설정하고 블록킹 방식의 드라이버보다 성능 향상을 필요로 할 때는 Future 방식으로 비동기 처리를 해주는 jasync-sql과 같은 드라이버를 사용할 수 도 있습니다. jasync-sql 를 사용 시 높은 유연성과 성능을 기대할 수 있지만 GameAnvil 에서 실행 시 몇가지 주의할 점이 있습니다. 이에 대한 내용은 아래 Pinning 문제 문단을 참고하십시오.
[참고]
모든 라이브러리가 Virtual Thread 를 지원하는 것은 아닙니다. 이전 버전에 맞춰 제작된 라이브러리를 GameAnvil 에서 실행 시 정상적으로 동작하지 않을 수 있습니다. 예를 들어 MySQL Connector/J 는 9.x 버전 부터 Virtual Thread 를 지원합니다. 8.x 버전을 사용 시 정상적으로 동작하지 않을 수 있습니다.
많이 사용되고 있는 라이브러리는 Jedis 를 사용하고 있는 곳이 더 많지만 엔진팀 내부 확인 결과 Jedis 는 Virtual Thread 사용 시 스레드가 잠기는 문제가 발생할 수 있습니다. GameAnvil 에서는 Virtual Thread 를 사용하고 있어 이러한 문제가 자주 발생하며 문제 발생 시 디버깅이 어려워 매우 탐지하기 어렵습니다. 만약 Jedis 사용을 고려하고 있다면 Lettuce 사용을 권장합니다. 이미 Jedis 를 사용하고 있어 마이그레이션이 어려울 때는 다음과 같이 다른 스레드 풀에서 jedis 를 실행하는 코드로 사용하여 스레드가 잠기는 문제를 회피할 수 있습니다.
final String myKey = ForkJoinPool.commonPool().submit(() -> {
return jedis.get("my_key");
});
GameAnvil 에서는 Redis 사용을 위해 Lettuce 사용을 권장합니다. Lettuce 를 GameAnvil 에서 사용 시 높은 성능을 기대할 수 있지만 몇가지 주의할 점이 있습니다. 이에 대한 내용은 아래 Pinning 문제 문단을 참고하십시오
// Main.java
int loopCount = Runtime.getRuntime().availableProcessors() * 2;
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < loopCount; i++) {
Thread t = Thread.ofVirtual().start(() -> {
System.out.println("hello"); // System.out.println 안에서 ReentrantLock 를 사용합니다
synchronized (Main.class) {
System.out.println("synchronized");
}
});
threads.add(t);
}
for (Thread thread : threads) {
thread.join();
}
위 코드를 실행시 프로그램이 종료되지 않습니다. System.out.println 안에서 ReentrantLock 를 사용 하고 있기 때문에 잠기는 문제인데 이렇게 잠기게된 Virtual Thread 는 혼자서는 탈출 할 수 없으며 다른 스레드가 Virtual Thread 를 풀어줘야만 계속 동작할 수 있습니다. 위 예제에서는 availableProcessors * 2
로 작성되어 항상 프로그램이 멈추게 됩니다만 이 코드를 availableProcessors - 1
로 변경 후 실행 시 정상적으로 종료됩니다. Virtual Thread 를 실행하는 Carrier Thread 풀의 기본 값이 availableProcessors
이기 때문입니다.
이러한 문제를 테스트 환경에서 제거하기 위하여 아래 JVM 옵션을 추가할 수 있습니다
-Djdk.tracePinnedThreads=full
VM 옵션 추가 후 위 프로그램을 다시 실행 시 아래와 같은 경고가 출력되어 문제가 발생하는 코드를 확인할 수 있습니다.
Thread[#46,ForkJoinPool-1-worker-3,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
java.base/java.lang.VirtualThread.park(VirtualThread.java:582)
java.base/java.lang.System$2.parkVirtualThread(System.java:2643)
java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)
java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:219)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:990)
java.base/java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:153)
java.base/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322)
java.base/jdk.internal.misc.InternalLock.lock(InternalLock.java:74)
java.base/java.io.PrintStream.writeln(PrintStream.java:824)
java.base/java.io.PrintStream.println(PrintStream.java:1168)
org.example.Main.lambda$main$0(Main.java:30) <== monitors:1
java.base/java.lang.VirtualThread.run(VirtualThread.java:309)
GameAnvil 에서는 엔진 사용을 위해 커스텀한 Virtual Thread 를 사용하고 있기 때문에 위와 같은 문제가 발생 시 잠금이 해제되지 않을 수 있습니다. jasync-sql, Lettuce 사용 시 에도 Connection 을 만드는 부분에서 Pinning 경고가 출력합니다. Connection 연결 이외에서는 문제가 발생하지 않으므로 Connection 생성 시 메인 스레드 혹은 다른 스레드 에서 생성하는 작업을 넣어주는 것이 필요합니다. 아래는 jasync-sql 을 사용하여 GameAnvil 서버 시작 전 먼저 MySql ConnectionPool 을 만드는 예제입니다.
public static Connection connectionPool;
public static void main(String[] args) {
// 먼저 Database를 연결합니다
connectionPool = MySQLConnectionBuilder.createConnectionPool(
"jdbc:mysql://localhost/test", config -> {
config.setUsername("db_user");
config.setPassword("db_password");
config.setMaxActiveConnections(30);
return Unit.INSTANCE;
});
// 이후 GameAnvil 초기화를 합니다
GameAnvilServer gameAnvilServer = GameAnvilServer.getInstance();
}
이러한 Pinning 에 대한 자세한 설명은 openJDK 의 Virtual Threads#Pinning 항목 에서 확인할 수 있습니다. Virtual Thread 에서 synchronized 제한은 이후 Java 릴리즈에서 제거될 수 있습니다. 자세한 사항은 Synchronize Virtual Threads without Pinning 에서 확인할 수 있습니다.