diff --git a/src/runtime/proc.go b/src/runtime/proc.go
index db7a5b2bb1c654568139595b070de4c9dab24c3f..44c6d0b4e43ca3a3e9202c5df620bc4d3a8e60aa 100644
--- a/src/runtime/proc.go
+++ b/src/runtime/proc.go
@@ -3387,8 +3387,12 @@ top:
 	// blocked thread (e.g. it has already returned from netpoll, but does
 	// not set lastpoll yet), this thread will do blocking netpoll below
 	// anyway.
-	if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
-		if list, delta := netpoll(0); !list.empty() { // non-blocking
+	// We only poll from one thread at a time to avoid kernel contention
+	// on machines with many cores.
+	if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 && sched.pollingNet.Swap(1) == 0 {
+		list, delta := netpoll(0)
+		sched.pollingNet.Store(0)
+		if !list.empty() { // non-blocking
 			gp := list.pop()
 			injectglist(&list)
 			netpollAdjustWaiters(delta)
diff --git a/src/runtime/runtime2.go b/src/runtime/runtime2.go
index e56b45053e122e9fc3046b200a438e997ff5aa47..27d14b890bd654708d7f630c4621c1e34fe738f3 100644
--- a/src/runtime/runtime2.go
+++ b/src/runtime/runtime2.go
@@ -757,9 +757,10 @@ type p struct {
 }
 
 type schedt struct {
-	goidgen   atomic.Uint64
-	lastpoll  atomic.Int64 // time of last network poll, 0 if currently polling
-	pollUntil atomic.Int64 // time to which current poll is sleeping
+	goidgen    atomic.Uint64
+	lastpoll   atomic.Int64 // time of last network poll, 0 if currently polling
+	pollUntil  atomic.Int64 // time to which current poll is sleeping
+	pollingNet atomic.Int32 // 1 if some P doing non-blocking network poll
 
 	lock mutex