Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove virtual thread support for Undertow as it leaks memory #39812

Closed
Tythor opened this issue Mar 1, 2024 · 22 comments
Closed

Remove virtual thread support for Undertow as it leaks memory #39812

Tythor opened this issue Mar 1, 2024 · 22 comments
Assignees
Labels
status: noteworthy A noteworthy issue to call out in the release notes type: bug A general bug
Milestone

Comments

@Tythor
Copy link

Tythor commented Mar 1, 2024

Hello,

I am observing an issue with virtual threads in Undertow in Spring Boot v3.3.0-M2. When using spring.threads.virtual.enabled=true in the application.properties or

@Bean
public UndertowDeploymentInfoCustomizer undertowDeploymentInfoCustomizer() {
 return deploymentInfo -> deploymentInfo.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}

as a configuration, the memory usage of the application increases dramatically under load. I performed a wrk test with the command wrk -d30 -t10 -c150 http://localhost:8080 and my application's memory usage increased to 20GB in just 30 seconds.
image

Interestingly, when using a profiler, I observed that the JVM reported significantly lower memory usage than what the activity monitor was showing.
image

The application eventually crashes with an OOM error:

2024-02-29T19:08:44.404-08:00 ERROR 91152 --- [ndertow-1044448] io.undertow.request                      : UT005023: Exception handling request to /

jakarta.servlet.ServletException: Handler dispatch failed: java.lang.OutOfMemoryError: Cannot reserve 16364 bytes of direct buffer memory (allocated: 17179860008, limit: 17179869184)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1104) ~[spring-webmvc-6.1.4.jar:6.1.4]
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) ~[spring-webmvc-6.1.4.jar:6.1.4]
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) ~[spring-webmvc-6.1.4.jar:6.1.4]
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903) ~[spring-webmvc-6.1.4.jar:6.1.4]
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:527) ~[jakarta.servlet-api-6.0.0.jar:6.0.0]
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) ~[spring-webmvc-6.1.4.jar:6.1.4]
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:614) ~[jakarta.servlet-api-6.0.0.jar:6.0.0]
at io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:129) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at com.example.demo.RequestFilter.doFilter(RequestFilter.java:19) ~[main/:na]
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-6.1.4.jar:6.1.4]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.4.jar:6.1.4]
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-6.1.4.jar:6.1.4]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.4.jar:6.1.4]
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-6.1.4.jar:6.1.4]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.1.4.jar:6.1.4]
at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.FilterHandler.handleRequest(FilterHandler.java:84) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.ServletDispatchingHandler.handleRequest(ServletDispatchingHandler.java:36) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.RedirectDirHandler.handleRequest(RedirectDirHandler.java:68) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.security.SSLInformationAssociationHandler.handleRequest(SSLInformationAssociationHandler.java:117) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:57) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43) ~[undertow-core-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46) ~[undertow-core-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.security.handlers.AuthenticationMechanismsHandler.handleRequest(AuthenticationMechanismsHandler.java:60) ~[undertow-core-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.security.CachedAuthenticatedSessionHandler.handleRequest(CachedAuthenticatedSessionHandler.java:77) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43) ~[undertow-core-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43) ~[undertow-core-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.SendErrorPageHandler.handleRequest(SendErrorPageHandler.java:52) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43) ~[undertow-core-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:276) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:135) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:132) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:256) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.servlet.handlers.ServletInitialHandler$1.handleRequest(ServletInitialHandler.java:101) ~[undertow-servlet-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.server.Connectors.executeRootHandler(Connectors.java:393) ~[undertow-core-2.3.12.Final.jar:2.3.12.Final]
at io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:859) ~[undertow-core-2.3.12.Final.jar:2.3.12.Final]
at java.base/java.lang.VirtualThread.run(VirtualThread.java:309) ~[na:na]

Output of java -version

openjdk version "21.0.2" 2024-01-16
OpenJDK Runtime Environment GraalVM CE 21.0.2+13.1 (build 21.0.2+13-jvmci-23.1-b30)
OpenJDK 64-Bit Server VM GraalVM CE 21.0.2+13.1 (build 21.0.2+13-jvmci-23.1-b30, mixed mode, sharing)

I was able to reproduce this behavior when running the application in a Docker container, when compiling to a native-image, on different JDKs (OpenJDK, GraalVM, and Corretto), and on different macOS architectures (Intel and Apple silicon).
Tagging related issue #38819.

Thank you.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Mar 1, 2024
@mhalbritter
Copy link
Contributor

mhalbritter commented Mar 1, 2024

That's one of the drawbacks when moving from a thread pool to virtual threads per task without a pool. When using thread pools, they usually have an upper bound for the size, limiting the maximum resource consumption. Unpooled virtual threads don't have that. I assume the application you're benchmarking is a simple demo application which just returns some hardcoded data? Because usually, if there's no pool in the webserver, some other pool in the application is limiting the throughput (e.g. the connection pool to the database).

Btw, you don't see the memory usage in VisualVM, as this is direct allocated memory, which is off-heap.

@mhalbritter mhalbritter added the status: waiting-for-feedback We need additional information before we can continue label Mar 1, 2024
@Tythor
Copy link
Author

Tythor commented Mar 1, 2024

Yes, it's the Spring Boot demo project with a basic GET endpoint with no return value. Sorry, could you explain the cause for this again? Aren't virtual threads still limited by the size of the platform threads in the carrier pool? The Tomcat and Jetty servlets don't seem to have the same issue when using virtual threads. Also, thank you for the explanation about the profiler.

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Mar 1, 2024
@mhalbritter
Copy link
Contributor

mhalbritter commented Mar 1, 2024

One possibility could be this:

1. VT1: Start accepting the request
2. VT1: Allocate memory
3. VT1: Block on something (maybe reading http from the TCP socket)
4. VT1: Handle request
5. VT1: Free memory

With enough requests lined up, the block at 3. could be the reason why the memory is exhausted. At this block, the scheduler frees the underlying platform thread, and switches to a new virtual thread, which allocates memory, blocks, a new virtual thread is scheduled, allocates memory, etc etc.

@mhalbritter
Copy link
Contributor

You could use a SimpleAsyncTaskExecutor with setConcurrencyLimit to limit the maximum allowed number of virtual threads.

@MetaiR
Copy link

MetaiR commented Mar 1, 2024

as long as I remember, there is an issue that talks about virtual threads in Spring Boot here:
#38819

there is one part I saw that says in version 3.3.x the usage of the virtual thread must automatically happen for undertow. so can it be related to this issue?

can u check if this also happens with version 3.2.x ? @Tythor

@Tythor
Copy link
Author

Tythor commented Mar 2, 2024

@MetaiR
Yes, I can confirm the same behavior happens in v3.2.3. Since spring.threads.virtual.enabled=true does not apply to Undertow servlets until v3.3.0, I had to use the undertowDeploymentInfoCustomizer to enable virtual threading.

@mhalbritter
I attempted to use a SimpleAsyncTaskExecutor with virtual threads with a concurrency limit of 10, but the issue still persisted.

@Bean
public UndertowDeploymentInfoCustomizer undertowDeploymentInfoCustomizer() {
    return deploymentInfo -> {
        SimpleAsyncTaskExecutor simpleAsyncTaskExecutor = new SimpleAsyncTaskExecutor();
        simpleAsyncTaskExecutor.setVirtualThreads(true);
        simpleAsyncTaskExecutor.setConcurrencyLimit(10);
        deploymentInfo.setExecutor(simpleAsyncTaskExecutor);
    };
}

However, this led me to try using a non virtual threaded executor instead. Surprisingly, I observed the same behavior, albeit at a much slower growth rate of about 2m for 20GB instead of 30s. These three executors produced similar results:

deploymentInfo.setExecutor(new SimpleAsyncTaskExecutor());
deploymentInfo.setExecutor(Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory()));
deploymentInfo.setExecutor(Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory()));

However, when using

deploymentInfo.setExecutor(Executors.newCachedThreadPool());

the memory growth disappeared. Looking into the implementation, the main difference is that the cachedThreadPool executor has a keepAliveTime of 60s, while the others have a keepAliveTime of 0s.

Reducing the keepAliveTime to as low as 1s did not cause the memory growth in my tests. But setting it to 0s reproduced the same issue.

deploymentInfo.setExecutor(new ThreadPoolExecutor(0, Integer.MAX_VALUE, 1L, TimeUnit.SECONDS, new SynchronousQueue<>()));

So it looks like this issue is not unique to virtual threads, but perhaps with the way Undertow is handling executors?

@mhalbritter
Copy link
Contributor

Yeah, looks like something is wrong here, but it doesn't seem to be in Spring Boot. Please open an issue on the Undertow tracker, and feel free to drop the link in this issue. Thanks!

@mhalbritter mhalbritter closed this as not planned Won't fix, can't repro, duplicate, stale Mar 4, 2024
@mhalbritter mhalbritter added status: invalid An issue that we don't feel is valid for: external-project For an external project and not something we can fix status: waiting-for-triage An issue we've not yet triaged and removed status: waiting-for-triage An issue we've not yet triaged status: feedback-provided Feedback has been provided status: invalid An issue that we don't feel is valid for: external-project For an external project and not something we can fix labels Mar 4, 2024
@mhalbritter mhalbritter reopened this Mar 4, 2024
@mhalbritter
Copy link
Contributor

We decided to reopen the issue. The out of box experience with virtual threads in undertow is bad, and if we (or the undertow team) can't fix the problems with it, we might remove virtual threads support for undertow again.

@mhalbritter mhalbritter changed the title Virtual Threads in Undertow (Memory Issue) Virtual threads in Undertow leak memory Mar 4, 2024
@Tythor
Copy link
Author

Tythor commented Mar 4, 2024

Okay, I was about to suggest disabling the auto configuration for spring.threads.virtual.enabled=true in v3.3.x. This issue would likely be a problem that most users will not be aware of. Thanks!
cff1b33

@mhalbritter
Copy link
Contributor

mhalbritter commented Mar 4, 2024

Yeah, we shouldn't fiddle with the Undertow executors.

The default one produces this plot:

default

As soon as we meddle with it, it starts breaking.

With virtual threads:

VT

With virtual threads and concurrency throttle to 200:

VT-concurrency-200

With platform threads and concurrency throttle to 200:

PT-200

It breaks in all configurations when setting the executor.

I'm going to revert the virtual thread support for Undertow.

@mhalbritter mhalbritter added type: bug A general bug and removed status: waiting-for-triage An issue we've not yet triaged labels Mar 4, 2024
@mhalbritter mhalbritter added this to the 3.3.x milestone Mar 4, 2024
@mhalbritter mhalbritter self-assigned this Mar 4, 2024
@mhalbritter mhalbritter changed the title Virtual threads in Undertow leak memory Remove virtual thread support for Undertow as it leaks memory Mar 4, 2024
@mhalbritter mhalbritter modified the milestones: 3.3.x, 3.3.0-M3 Mar 4, 2024
@wilkinsona wilkinsona added the status: noteworthy A noteworthy issue to call out in the release notes label Mar 4, 2024
@wilkinsona
Copy link
Member

It looks like Undertow may have a memory leak. @Tythor, can you please retry your scenario with Undertow downgraded to 2.3.10.Final and see if it helps?

@Tythor
Copy link
Author

Tythor commented Mar 6, 2024

Hi @wilkinsona, downgrading Undertow to 2.3.10.Final on Spring Boot v3.3.0-M2 did significantly slow the memory growth. However, I am still seeing suspicious behavior when providing an executor in the UndertowDeploymentInfoCustomizer. Although I'm not sure if it's due to a memory leak or just inefficient memory consumption within Undertow. Perhaps @mhalbritter may be able to diagnose the issue better with his plots. I also tried using 2.3.11.Final and encountered the same issue as the one in 2.3.12.Final.

@mhalbritter
Copy link
Contributor

Undertow 2.3.10.Final, SimpleAsyncTaskExecutor with 200 concurrency limit and virtual threads enabled:

VT-200

It's better, but there's still a problem somewhere.

@mhalbritter
Copy link
Contributor

This is what 2.3.10 with the default executor looks like:

default

@Tythor
Copy link
Author

Tythor commented Mar 7, 2024

Thank you @mhalbritter, these plots are very similar to the results I observed as well.

@mhalbritter
Copy link
Contributor

Btw, the tool I used is https://github.com/astrofrog/psrecord.

@cassiusvm
Copy link

Hello,

I am using Spring Boot 3.2.4 and Undertow with OpenJDK by Corretto, I don't have this issue.

My application is an API REST, it is running by several days.

@wilkinsona
Copy link
Member

@cassiusvm How have you configured Undertow to use virtual threads?

@cassiusvm
Copy link

@wilkinsona Right, using the @configuration tip in a Docker image jelastic maven 3.9.5 OpenJDK 21.

My bad, I said Correto.

@time4tea
Copy link

I realise its not a spring issue per-se, but hoping that this is useful.

When configuring with

Xnio.getInstance().setExternalExecutorService(Executors.newVirtualThreadPerTaskExecutor())

The service will quickly blow up.. I think due to use of ThreadLocal buffer strategy.

image

In DefaultByteBufferPool, the following line might cause a problem with virtual threads:

ThreadLocalData get() {
            return localsByThread.get(Thread.currentThread());
        }

It is possible to make it used unpooled buffers, with

io.undertow.Undertow.builder().setByteBufferPool(XnioByteBufferPool(Pool.DIRECT))

but the performance of this is worse (by 50%) than the default, so no point.

Anyhow, hope thats of some use if somebody is looking into this.

graemerocher added a commit to micronaut-projects/micronaut-servlet that referenced this issue May 22, 2024
Allows enabling virtual thread support for Jetty and Tomcat.

Undertow has issues with Virtual threads so not implemented. See spring-projects/spring-boot#39812

Co-authored-by: Sergio del Amo <sergio.delamo@softamo.com>

---------

Co-authored-by: Sergio del Amo <sergio.delamo@softamo.com>
@JavaLionLi
Copy link

JavaLionLi commented Jun 25, 2024

image

@philwebb
Copy link
Member

Thanks @JavaLionLi. I'll update #38819 with that info.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: noteworthy A noteworthy issue to call out in the release notes type: bug A general bug
Projects
None yet
Development

No branches or pull requests

9 participants