Skip to content

Commit bbd7564

Browse files
committed
optimize: fix gRPC conn pool leak
Previously a closed/broken transport stayed in the nphttp2 client pool indefinitely, because only a later put() could overwrite the slot. A new OnClose callback now evicts the transport via transports.remove() as soon as it closes, and all cliTransports accesses are serialized by a new sync.RWMutex. To avoid a lock-order inversion between transports.mu (which doPut holds while calling IsActive -> http2Client.mu) and the onClose -> remove path (which takes transports.mu while http2Client.Close historically held t.mu), http2Client.Close now releases t.mu before invoking onClose. The invariant transports.mu -> http2Client.mu is documented on doPut, remove, close, and http2Client.Close. GracefulClose is now invoked outside transports.mu in both put() and close() to avoid re-entering the mutex through onClose -> remove on transports with zero active streams. Dump() snapshots (addr, transport) refs under RLock and calls each transport's Dump() after releasing the lock. The pool-side callback signatures now take (ctx, trans, err) instead of (). A new grpc.ClientConfig + NewClientTransportWithConfig expose the new shape; the deprecated NewClientTransport is preserved as a thin adapter. Note: the deprecated NewClientTransport path has a visible timing change - onClose is now invoked after state is set to closing and after http2Client.mu is released, whereas it previously ran before the state change while the mutex was held. Callers relying on the old ordering must migrate to NewClientTransportWithConfig.
1 parent a86ecf4 commit bbd7564

9 files changed

Lines changed: 1126 additions & 154 deletions

File tree

pkg/remote/trans/nphttp2/conn_pool.go

Lines changed: 130 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,16 @@ import (
3030

3131
"github.com/cloudwego/kitex/pkg/klog"
3232
"github.com/cloudwego/kitex/pkg/remote"
33+
"github.com/cloudwego/kitex/pkg/remote/trans/nphttp2/codes"
3334
"github.com/cloudwego/kitex/pkg/remote/trans/nphttp2/grpc"
35+
"github.com/cloudwego/kitex/pkg/remote/trans/nphttp2/status"
3436
"github.com/cloudwego/kitex/pkg/rpcinfo"
3537
)
3638

37-
var _ remote.LongConnPool = &connPool{}
39+
const (
40+
poolOpen int32 = 0
41+
poolClosed int32 = 1
42+
)
3843

3944
func poolSize() uint32 {
4045
// One connection per processor, and need redundancy。
@@ -60,84 +65,20 @@ func NewConnPool(remoteService string, size uint32, connOpts grpc.ConnectOptions
6065
}
6166
}
6267

63-
// MuxPool manages a pool of long connections.
68+
// connPool manages a pool of gRPC long connections.
6469
type connPool struct {
6570
size uint32
6671
sfg singleflight.Group
6772
conns sync.Map // key: address, value: *transports
6873
remoteService string // remote service name
6974
connOpts grpc.ConnectOptions
75+
closed int32 // 1 means connPool has been closed
7076
}
7177

72-
type transports struct {
73-
index uint32
74-
size uint32
75-
cliTransports []grpc.ClientTransport
76-
}
77-
78-
// get connection from the pool, load balance with round-robin.
79-
func (t *transports) get() grpc.ClientTransport {
80-
idx := atomic.AddUint32(&t.index, 1)
81-
return t.cliTransports[idx%t.size]
82-
}
83-
84-
// put find the first empty position to put the connection to the pool.
85-
func (t *transports) put(trans grpc.ClientTransport) {
86-
for i := 0; i < int(t.size); i++ {
87-
cliTransport := t.cliTransports[i]
88-
if cliTransport == nil {
89-
t.cliTransports[i] = trans
90-
return
91-
}
92-
if !cliTransport.(grpc.IsActive).IsActive() {
93-
t.cliTransports[i].GracefulClose()
94-
t.cliTransports[i] = trans
95-
return
96-
}
97-
}
98-
}
99-
100-
// close all connections of the pool.
101-
func (t *transports) close() {
102-
for i := range t.cliTransports {
103-
if c := t.cliTransports[i]; c != nil {
104-
c.GracefulClose()
105-
}
106-
}
107-
}
108-
109-
var _ remote.LongConnPool = (*connPool)(nil)
110-
111-
func (p *connPool) newTransport(ctx context.Context, dialer remote.Dialer, network, address string,
112-
connectTimeout time.Duration, opts grpc.ConnectOptions,
113-
) (grpc.ClientTransport, error) {
114-
conn, err := dialer.DialTimeout(network, address, connectTimeout)
115-
if err != nil {
116-
return nil, err
117-
}
118-
if opts.TLSConfig != nil {
119-
tlsConn, err := newTLSConn(conn, opts.TLSConfig)
120-
if err != nil {
121-
return nil, err
122-
}
123-
conn = tlsConn
124-
}
125-
return grpc.NewClientTransport(
126-
ctx,
127-
conn,
128-
opts,
129-
p.remoteService,
130-
func(grpc.GoAwayReason) {
131-
// remove connection from the pool.
132-
// we do not need to close this grpc transport manually
133-
// since grpc client is responsible for doing this.
134-
p.conns.Delete(address)
135-
},
136-
func() {
137-
// do nothing
138-
},
139-
)
140-
}
78+
var (
79+
_ remote.LongConnPool = (*connPool)(nil)
80+
errConnPoolClosed = status.Err(codes.Aborted, "connection pool has been closed")
81+
)
14182

14283
// Get pick or generate a net.Conn and return
14384
func (p *connPool) Get(ctx context.Context, network, address string, opt remote.ConnOption) (net.Conn, error) {
@@ -147,47 +88,60 @@ func (p *connPool) Get(ctx context.Context, network, address string, opt remote.
14788

14889
var (
14990
trans *transports
91+
tr grpc.ClientTransport
92+
idx uint32
15093
conn *clientConn
15194
err error
15295
)
15396

97+
// there is no need to check whether connPool has been closed
98+
// because connPool would only be closed when Kitex Client is GCed
15499
v, ok := p.conns.Load(address)
155100
if ok {
156101
trans = v.(*transports)
157-
if tr := trans.get(); tr != nil {
158-
if tr.(grpc.IsActive).IsActive() {
159-
// Actually new a stream, reuse the connection (grpc.ClientTransport)
160-
conn, err = newClientConn(ctx, tr, address)
161-
if err == nil {
162-
return conn, nil
163-
}
164-
klog.CtxDebugf(ctx, "KITEX: New grpc stream failed, network=%s, address=%s, error=%s", network, address, err.Error())
102+
tr, idx = trans.getActiveTransport()
103+
if tr != nil {
104+
// Actually new a stream, reuse the connection (grpc.ClientTransport)
105+
conn, err = newClientConn(ctx, tr, address)
106+
if err == nil {
107+
return conn, nil
165108
}
109+
klog.CtxDebugf(ctx, "KITEX: New grpc stream failed, network=%s, address=%s, error=%s", network, address, err.Error())
166110
}
167111
}
168-
tr, err, _ := p.sfg.Do(address, func() (i interface{}, e error) {
169-
// Notice: newTransport means new a connection, the timeout of connection cannot be set,
170-
// so using context.Background() but not the ctx passed in as the parameter.
171-
tr, err := p.newTransport(context.Background(), opt.Dialer, network, address, opt.ConnectTimeout, p.connOpts)
172-
if err != nil {
173-
return nil, err
112+
rawTr, dErr, _ := p.sfg.Do(address, func() (i interface{}, e error) {
113+
// avoid creating duplicate transports
114+
var isNew bool
115+
if existTrans, ok := p.conns.Load(address); ok {
116+
trans = existTrans.(*transports)
117+
} else {
118+
trans = newTransports(p.size)
119+
isNew = true
174120
}
175-
if trans == nil {
176-
trans = &transports{
177-
size: p.size,
178-
cliTransports: make([]grpc.ClientTransport, p.size),
121+
122+
res, cErr := trans.createTransport(idx, p.remoteService, opt.Dialer, network, address, opt.ConnectTimeout, p.connOpts)
123+
if cErr != nil {
124+
return nil, cErr
125+
}
126+
127+
if isNew {
128+
if !p.isClosed() {
129+
p.conns.LoadOrStore(address, trans)
130+
} else {
131+
// Concurrent Get() and Close(): avoid leaking newly created gRPC connection after Close().
132+
trans.close()
133+
return nil, errConnPoolClosed
179134
}
180135
}
181-
trans.put(tr) // the tr (connection) maybe not in the pool, but can be recycled by keepalive.
182-
p.conns.Store(address, trans)
183-
return tr, nil
136+
137+
return res, nil
184138
})
185-
if err != nil {
186-
klog.CtxErrorf(ctx, "KITEX: New grpc client connection failed, network=%s, address=%s, error=%s", network, address, err.Error())
187-
return nil, err
139+
if dErr != nil {
140+
klog.CtxErrorf(ctx, "KITEX: New grpc client connection failed, network=%s, address=%s, error=%s", network, address, dErr.Error())
141+
return nil, dErr
188142
}
189143
klog.CtxDebugf(ctx, "KITEX: New grpc client connection succeed, network=%s, address=%s", network, address)
190-
return newClientConn(ctx, tr.(grpc.ClientTransport), address)
144+
return newClientConn(ctx, rawTr.(grpc.ClientTransport), address)
191145
}
192146

193147
// Put implements the ConnPool interface.
@@ -205,9 +159,7 @@ func (p *connPool) release(conn net.Conn) error {
205159
}
206160

207161
func (p *connPool) createShortConn(ctx context.Context, network, address string, opt remote.ConnOption) (net.Conn, error) {
208-
// Notice: newTransport means new a connection, the timeout of connection cannot be set,
209-
// so using context.Background() but not the ctx passed in as the parameter.
210-
tr, err := p.newTransport(context.Background(), opt.Dialer, network, address, opt.ConnectTimeout, p.connOpts)
162+
tr, err := newTransport(p.remoteService, opt.Dialer, network, address, opt.ConnectTimeout, p.connOpts, nil, nil)
211163
if err != nil {
212164
return nil, err
213165
}
@@ -224,14 +176,17 @@ func (p *connPool) Discard(conn net.Conn) error {
224176

225177
// Clean implements the LongConnPool interface.
226178
func (p *connPool) Clean(network, address string) {
227-
if v, ok := p.conns.Load(address); ok {
228-
p.conns.Delete(address)
179+
if v, ok := p.conns.LoadAndDelete(address); ok {
229180
v.(*transports).close()
230181
}
231182
}
232183

233184
// Close is to release resource of ConnPool, it is executed when client is closed.
234185
func (p *connPool) Close() error {
186+
if !p.casClosed() {
187+
return nil
188+
}
189+
235190
p.conns.Range(func(addr, trans interface{}) bool {
236191
p.conns.Delete(addr)
237192
trans.(*transports).close()
@@ -240,6 +195,19 @@ func (p *connPool) Close() error {
240195
return nil
241196
}
242197

198+
func (p *connPool) isClosed() bool {
199+
return atomic.LoadInt32(&p.closed) == poolClosed
200+
}
201+
202+
func (p *connPool) casClosed() bool {
203+
return atomic.CompareAndSwapInt32(&p.closed, poolOpen, poolClosed)
204+
}
205+
206+
type dumpEntry struct {
207+
addr string
208+
tr grpc.ClientTransport
209+
}
210+
243211
// Dump dumps the connection pool with the details of the underlying transport.
244212
func (p *connPool) Dump() interface{} {
245213
defer func() {
@@ -251,32 +219,67 @@ func (p *connPool) Dump() interface{} {
251219
// remoteAddress -> []clientTransport, where each clientTransport is a connection. Distinguish the connection via localAddress.
252220
// If mesh egress is not enabled, toAddr should be the address of the callee service.
253221
// Otherwise, toAddr will be the same, so you should check the remoteAddress in each stream, which is read from the header.
222+
223+
// sync.Map does not expose its length directly.
224+
// Since dump is a cold-path operation, performance is not a major concern here
254225
poolDump := make(map[string]interface{}, p.size)
226+
var cliTransDumps []dumpEntry
255227
p.conns.Range(func(k, v interface{}) bool {
256228
addr := k.(string)
257-
ts := v.(*transports)
258-
for _, t := range ts.cliTransports {
259-
if t == nil {
260-
continue
261-
}
262-
dumper, ok := t.(interface{ Dump() interface{} })
263-
if !ok {
264-
continue
265-
}
266-
var curr []interface{}
267-
if poolDump[addr] == nil {
268-
curr = make([]interface{}, 0)
269-
} else {
270-
curr = poolDump[addr].([]interface{})
271-
}
272-
curr = append(curr, dumper.Dump())
273-
poolDump[addr] = curr
229+
for _, tr := range v.(*transports).loadAll() {
230+
cliTransDumps = append(cliTransDumps, dumpEntry{addr: addr, tr: tr})
274231
}
275232
return true
276233
})
234+
235+
for _, cliTransDump := range cliTransDumps {
236+
dumper, ok := cliTransDump.tr.(interface{ Dump() interface{} })
237+
if !ok {
238+
continue
239+
}
240+
var curr []interface{}
241+
if poolDump[cliTransDump.addr] == nil {
242+
curr = make([]interface{}, 0)
243+
} else {
244+
curr = poolDump[cliTransDump.addr].([]interface{})
245+
}
246+
curr = append(curr, dumper.Dump())
247+
poolDump[cliTransDump.addr] = curr
248+
}
277249
return poolDump
278250
}
279251

252+
// newTransport creates a gRPC connection
253+
func newTransport(remoteService string,
254+
dialer remote.Dialer, network, address string, connectTimeout time.Duration, opts grpc.ConnectOptions,
255+
onGoAway func(context.Context, grpc.ClientTransport, grpc.GoAwayReason),
256+
onClose func(context.Context, grpc.ClientTransport, error),
257+
) (grpc.ClientTransport, error) {
258+
conn, err := dialer.DialTimeout(network, address, connectTimeout)
259+
if err != nil {
260+
return nil, err
261+
}
262+
if opts.TLSConfig != nil {
263+
tlsConn, tErr := newTLSConn(conn, opts.TLSConfig)
264+
if tErr != nil {
265+
// release tls handshake failed connection
266+
conn.Close()
267+
return nil, tErr
268+
}
269+
conn = tlsConn
270+
}
271+
return grpc.NewClientTransportWithConfig(
272+
context.Background(), // gRPC connection does not need to be bound to a specific ctx
273+
conn,
274+
opts,
275+
grpc.ClientConfig{
276+
RemoteService: remoteService,
277+
OnGoAway: onGoAway,
278+
OnClose: onClose,
279+
},
280+
)
281+
}
282+
280283
// newTLSConn constructs a client-side TLS connection and performs handshake.
281284
func newTLSConn(conn net.Conn, tlsCfg *tls.Config) (net.Conn, error) {
282285
tlsConn := tls.Client(conn, tlsCfg)
@@ -285,3 +288,12 @@ func newTLSConn(conn net.Conn, tlsCfg *tls.Config) (net.Conn, error) {
285288
}
286289
return tlsConn, nil
287290
}
291+
292+
func checkActive(trans grpc.ClientTransport) bool {
293+
if trans == nil {
294+
return false
295+
}
296+
// grpc.ClientTransport is implemented by *http2Client in pkg/remote/trans/nphttp2/grpc
297+
// it implements grpc.IsActive
298+
return trans.(grpc.IsActive).IsActive()
299+
}

0 commit comments

Comments
 (0)