This is an automated email from the ASF dual-hosted git repository.

He-Pin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git


The following commit(s) were added to refs/heads/main by this push:
     new 6170b74560 refactor: reuse receive timeout state (#3334)
6170b74560 is described below

commit 6170b74560bdd8463e4d30cc949e4dabd8500407
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Wed Jul 22 02:51:01 2026 +0800

    refactor: reuse receive timeout state (#3334)
    
    Motivation:
    Actors with receive timeout enabled recreated two Tuple2 state objects for 
every influencing message, adding avoidable allocation pressure to the 
ActorCell hot path.
    
    Modification:
    Replace immutable tuple snapshots with one lazily allocated mutable state 
object and a per-invocation change counter. Add a burst-reset regression test 
and an end-to-end Actor JMH benchmark with GC profiling.
    
    Result:
    On JDK 17 the real Actor benchmark reduces stable allocation from 168.018 
to 120.018 B/message, exactly 48 B/message or 28.6%. Throughput remains neutral 
within the confidence interval.
    
    Tests:
    - actor-tests / Test / testOnly org.apache.pekko.actor.ReceiveTimeoutSpec 
(13 passed)
    - bench-jmh / Jmh / compile
    - ReceiveTimeoutBenchmark with JMH gc profiler on JDK 17.0.17
    - +mimaReportBinaryIssues (Scala 2.13 and Scala 3 passed)
    - +headerCheckAll and checkCodeStyle
    - scalafmt diff check and git diff --check
    - Qoder review: No must-fix findings
    - validatePullRequest not completed locally per user request; rely on CI
    
    References:
    Refs #1668
---
 .../apache/pekko/actor/ReceiveTimeoutSpec.scala    | 10 ++-
 .../scala/org/apache/pekko/actor/ActorCell.scala   |  2 +-
 .../pekko/actor/dungeon/ReceiveTimeout.scala       | 87 ++++++++++++--------
 .../pekko/actor/ReceiveTimeoutBenchmark.scala      | 96 ++++++++++++++++++++++
 4 files changed, 155 insertions(+), 40 deletions(-)

diff --git 
a/actor-tests/src/test/scala/org/apache/pekko/actor/ReceiveTimeoutSpec.scala 
b/actor-tests/src/test/scala/org/apache/pekko/actor/ReceiveTimeoutSpec.scala
index ef4ecb344f..72002a693e 100644
--- a/actor-tests/src/test/scala/org/apache/pekko/actor/ReceiveTimeoutSpec.scala
+++ b/actor-tests/src/test/scala/org/apache/pekko/actor/ReceiveTimeoutSpec.scala
@@ -104,18 +104,22 @@ class ReceiveTimeoutSpec extends PekkoSpec() {
 
     "reschedule timeout after regular receive" taggedAs TimingTest in {
       val timeoutLatch = TestLatch()
+      val messageCount = 100
+      val processedLatch = TestLatch(messageCount)
 
       val timeoutActor = system.actorOf(Props(new Actor {
-        context.setReceiveTimeout(500.milliseconds)
+        context.setReceiveTimeout(1.second)
 
         def receive = {
-          case Tick           => ()
+          case Tick           => processedLatch.countDown()
           case ReceiveTimeout => timeoutLatch.open()
         }
       }))
 
-      timeoutActor ! Tick
+      (1 to messageCount).foreach(_ => timeoutActor ! Tick)
 
+      Await.ready(processedLatch, TestLatch.DefaultTimeout)
+      intercept[TimeoutException] { Await.ready(timeoutLatch, 
500.milliseconds) }
       Await.ready(timeoutLatch, TestLatch.DefaultTimeout)
       system.stop(timeoutActor)
     }
diff --git a/actor/src/main/scala/org/apache/pekko/actor/ActorCell.scala 
b/actor/src/main/scala/org/apache/pekko/actor/ActorCell.scala
index 6bfbfedebd..7d36e05889 100644
--- a/actor/src/main/scala/org/apache/pekko/actor/ActorCell.scala
+++ b/actor/src/main/scala/org/apache/pekko/actor/ActorCell.scala
@@ -561,7 +561,7 @@ private[pekko] class ActorCell(
       }
     finally
       // Schedule or reschedule receive timeout
-      checkReceiveTimeoutIfNeeded(timeoutBeforeReceive)
+      checkReceiveTimeoutIfNeeded(msg, timeoutBeforeReceive)
   }
 
   def autoReceiveMessage(msg: Envelope): Unit = {
diff --git 
a/actor/src/main/scala/org/apache/pekko/actor/dungeon/ReceiveTimeout.scala 
b/actor/src/main/scala/org/apache/pekko/actor/dungeon/ReceiveTimeout.scala
index 3484337baa..2c897e2872 100644
--- a/actor/src/main/scala/org/apache/pekko/actor/dungeon/ReceiveTimeout.scala
+++ b/actor/src/main/scala/org/apache/pekko/actor/dungeon/ReceiveTimeout.scala
@@ -22,7 +22,8 @@ import pekko.actor.Cancellable
 import pekko.actor.dungeon.ReceiveTimeoutCompat
 
 private[pekko] object ReceiveTimeout {
-  final val emptyReceiveTimeoutData: (Duration, Cancellable) = 
(Duration.Undefined, ActorCell.emptyCancellable)
+  // Compared only across one message invocation, so wrapping over an actor's 
lifetime is harmless.
+  private final class State(var timeout: Duration, var task: Cancellable, var 
version: Int)
 }
 
 private[pekko] trait ReceiveTimeout { this: ActorCell =>
@@ -30,60 +31,74 @@ private[pekko] trait ReceiveTimeout { this: ActorCell =>
   import ActorCell._
   import ReceiveTimeout._
 
-  private var receiveTimeoutData: (Duration, Cancellable) = 
emptyReceiveTimeoutData
+  private var receiveTimeoutData: State = null
 
-  final def receiveTimeout: Duration = receiveTimeoutData._1
+  final def receiveTimeout: Duration =
+    if (receiveTimeoutData eq null) Duration.Undefined else 
receiveTimeoutData.timeout
 
-  final def setReceiveTimeout(timeout: Duration): Unit = receiveTimeoutData = 
receiveTimeoutData.copy(_1 = timeout)
+  final def setReceiveTimeout(timeout: Duration): Unit = {
+    val data = receiveTimeoutData
+    if (data eq null)
+      receiveTimeoutData = new State(timeout, emptyCancellable, version = 1)
+    else {
+      data.timeout = timeout
+      data.version += 1
+    }
+  }
 
   /** Called after `ActorCell.receiveMessage` or 
`ActorCell.autoReceiveMessage`. */
-  protected def checkReceiveTimeoutIfNeeded(beforeReceive: (Duration, 
Cancellable)): Unit = {
-    val timeoutChanged = receiveTimeoutChanged(beforeReceive)
+  protected def checkReceiveTimeoutIfNeeded(message: Any, 
beforeReceiveVersion: Int): Unit = {
+    val timeoutChanged = receiveTimeoutChanged(beforeReceiveVersion)
     if (hasTimeoutData || timeoutChanged)
-      checkReceiveTimeout(timeoutChanged)
+      
checkReceiveTimeout(!ReceiveTimeoutCompat.isNotInfluenceReceiveTimeout(message) 
|| timeoutChanged)
   }
 
   final def checkReceiveTimeout(reschedule: Boolean): Unit = {
-    val (recvTimeout, task) = receiveTimeoutData
-    recvTimeout match {
-      case f: FiniteDuration =>
-        // The fact that recvTimeout is FiniteDuration and task is 
emptyCancellable
-        // means that a user called `context.setReceiveTimeout(...)`
-        // while sending the ReceiveTimeout message is not scheduled yet.
-        // We have to handle the case and schedule sending the ReceiveTimeout 
message
-        // ignoring the reschedule parameter.
-        if (reschedule || (task eq emptyCancellable))
-          rescheduleReceiveTimeout(f)
-
-      case _ => cancelReceiveTimeoutTask()
+    val data = receiveTimeoutData
+    if (data ne null) {
+      data.timeout match {
+        case f: FiniteDuration =>
+          // The fact that timeout is FiniteDuration and task is 
emptyCancellable
+          // means that a user called `context.setReceiveTimeout(...)`
+          // while sending the ReceiveTimeout message is not scheduled yet.
+          // We have to handle the case and schedule sending the 
ReceiveTimeout message
+          // ignoring the reschedule parameter.
+          if (reschedule || (data.task eq emptyCancellable))
+            rescheduleReceiveTimeout(data, f)
+
+        case _ => cancelReceiveTimeoutTask()
+      }
     }
   }
 
-  private def rescheduleReceiveTimeout(f: FiniteDuration): Unit = {
-    receiveTimeoutData._2.cancel() // Cancel any ongoing future
-    val task = system.scheduler.scheduleOnce(f, self, 
pekko.actor.ReceiveTimeout)(this.dispatcher)
-    receiveTimeoutData = (f, task)
+  private def rescheduleReceiveTimeout(data: State, timeout: FiniteDuration): 
Unit = {
+    data.task.cancel() // Cancel any ongoing future
+    data.task = system.scheduler.scheduleOnce(timeout, self, 
pekko.actor.ReceiveTimeout)(this.dispatcher)
+    data.version += 1
   }
 
-  private def hasTimeoutData: Boolean = receiveTimeoutData ne 
emptyReceiveTimeoutData
+  private def hasTimeoutData: Boolean = receiveTimeoutData ne null
 
-  private def receiveTimeoutChanged(beforeReceive: (Duration, Cancellable)): 
Boolean =
-    receiveTimeoutData ne beforeReceive
+  private def receiveTimeoutVersion: Int =
+    if (receiveTimeoutData eq null) 0 else receiveTimeoutData.version
 
-  protected def cancelReceiveTimeoutIfNeeded(message: Any): (Duration, 
Cancellable) = {
-    val beforeReceive = receiveTimeoutData
-    if ((beforeReceive ne emptyReceiveTimeoutData) && 
!ReceiveTimeoutCompat.isNotInfluenceReceiveTimeout(message))
+  private def receiveTimeoutChanged(beforeReceiveVersion: Int): Boolean =
+    receiveTimeoutVersion != beforeReceiveVersion
+
+  protected def cancelReceiveTimeoutIfNeeded(message: Any): Int = {
+    if (hasTimeoutData && 
!ReceiveTimeoutCompat.isNotInfluenceReceiveTimeout(message))
       cancelReceiveTimeoutTask()
 
-    // Returning the state from before cancellation lets 
checkReceiveTimeoutIfNeeded infer whether this message
-    // influenced the timeout without performing the type check a second time.
-    beforeReceive
+    receiveTimeoutVersion
   }
 
-  private[pekko] def cancelReceiveTimeoutTask(): Unit =
-    if (receiveTimeoutData._2 ne emptyCancellable) {
-      receiveTimeoutData._2.cancel()
-      receiveTimeoutData = (receiveTimeoutData._1, emptyCancellable)
+  private[pekko] def cancelReceiveTimeoutTask(): Unit = {
+    val data = receiveTimeoutData
+    if ((data ne null) && (data.task ne emptyCancellable)) {
+      data.task.cancel()
+      data.task = emptyCancellable
+      data.version += 1
     }
+  }
 
 }
diff --git 
a/bench-jmh/src/main/scala/org/apache/pekko/actor/ReceiveTimeoutBenchmark.scala 
b/bench-jmh/src/main/scala/org/apache/pekko/actor/ReceiveTimeoutBenchmark.scala
new file mode 100644
index 0000000000..67556b08f1
--- /dev/null
+++ 
b/bench-jmh/src/main/scala/org/apache/pekko/actor/ReceiveTimeoutBenchmark.scala
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.actor
+
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+import scala.concurrent.duration._
+
+import com.typesafe.config.ConfigFactory
+import org.openjdk.jmh.annotations._
+
+@State(Scope.Benchmark)
+@BenchmarkMode(Array(Mode.Throughput))
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Fork(3)
+@Threads(1)
+@Warmup(iterations = 5, time = 1)
+@Measurement(iterations = 5, time = 1)
+class ReceiveTimeoutBenchmark {
+  import ReceiveTimeoutBenchmark._
+
+  private var system: ActorSystem = _
+  private var target: ActorRef = _
+
+  @Setup(Level.Trial)
+  def setup(): Unit = {
+    system = ActorSystem(
+      "ReceiveTimeoutBenchmark",
+      ConfigFactory.parseString("""
+        pekko {
+          log-dead-letters = off
+          scheduler.tick-duration = 1 ms
+          actor.default-dispatcher {
+            fork-join-executor {
+              parallelism-min = 1
+              parallelism-factor = 1.0
+              parallelism-max = 1
+            }
+            throughput = 1000
+          }
+        }
+      """))
+    target = system.actorOf(Props[TimeoutActor]())
+  }
+
+  @TearDown(Level.Trial)
+  def shutdown(): Unit =
+    system.close()
+
+  @Benchmark
+  @OperationsPerInvocation(MessageCount)
+  def influencingMessages(): Unit = {
+    val latch = new CountDownLatch(1)
+    var i = 0
+    while (i < MessageCount) {
+      target ! Message
+      i += 1
+    }
+    target ! new Finished(latch)
+    latch.await()
+  }
+}
+
+object ReceiveTimeoutBenchmark {
+  final val MessageCount = 10000
+
+  case object Message
+
+  final class Finished(val latch: CountDownLatch) extends 
NotInfluenceReceiveTimeout
+
+  final class TimeoutActor extends Actor {
+    context.setReceiveTimeout(100.millis)
+
+    override def receive: Receive = {
+      case Message            =>
+      case finished: Finished => finished.latch.countDown()
+      case ReceiveTimeout     =>
+    }
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to