Skip to content

Commit 4961d52

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 73d007a commit 4961d52

9 files changed

Lines changed: 1141 additions & 158 deletions

File tree

pkg/remote/trans/nphttp2/conn_pool.go

Lines changed: 140 additions & 122 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) {
@@ -146,48 +87,64 @@ func (p *connPool) Get(ctx context.Context, network, address string, opt remote.
14687
}
14788

14889
var (
149-
trans *transports
150-
conn *clientConn
151-
err error
90+
tr grpc.ClientTransport
91+
idx uint32
92+
conn *clientConn
93+
err error
15294
)
15395

96+
// there is no need to check whether connPool has been closed
97+
// because connPool would only be closed when Kitex Client is GCed
15498
v, ok := p.conns.Load(address)
15599
if ok {
156-
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())
100+
trans := v.(*transports)
101+
tr, idx = trans.getActiveTransport()
102+
if tr != nil {
103+
// Actually new a stream, reuse the connection (grpc.ClientTransport)
104+
conn, err = newClientConn(ctx, tr, address)
105+
if err == nil {
106+
return conn, nil
165107
}
108+
klog.CtxDebugf(ctx, "KITEX: New grpc stream failed, network=%s, address=%s, error=%s", network, address, err.Error())
166109
}
167110
}
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
111+
rawTr, dErr, _ := p.sfg.Do(address, func() (i interface{}, e error) {
112+
var trans *transports
113+
var isNew bool
114+
// avoid creating duplicate transports
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+
// Store first, then recheck closed state to eliminate TOCTOU:
129+
// if Close() finished its Range between our earlier check and this store,
130+
// self-clean here to prevent orphaned transports.
131+
p.conns.LoadOrStore(address, trans)
132+
if p.isClosed() {
133+
if recheckV, recheckOK := p.conns.LoadAndDelete(address); recheckOK {
134+
recheckV.(*transports).close()
135+
}
136+
return nil, errConnPoolClosed
179137
}
180138
}
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
139+
140+
return res, nil
184141
})
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
142+
if dErr != nil {
143+
klog.CtxErrorf(ctx, "KITEX: New grpc client connection failed, network=%s, address=%s, error=%s", network, address, dErr.Error())
144+
return nil, dErr
188145
}
189146
klog.CtxDebugf(ctx, "KITEX: New grpc client connection succeed, network=%s, address=%s", network, address)
190-
return newClientConn(ctx, tr.(grpc.ClientTransport), address)
147+
return newClientConn(ctx, rawTr.(grpc.ClientTransport), address)
191148
}
192149

193150
// Put implements the ConnPool interface.
@@ -205,9 +162,7 @@ func (p *connPool) release(conn net.Conn) error {
205162
}
206163

207164
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)
165+
tr, err := newTransport(p.remoteService, opt.Dialer, network, address, opt.ConnectTimeout, p.connOpts, nil, nil)
211166
if err != nil {
212167
return nil, err
213168
}
@@ -224,14 +179,17 @@ func (p *connPool) Discard(conn net.Conn) error {
224179

225180
// Clean implements the LongConnPool interface.
226181
func (p *connPool) Clean(network, address string) {
227-
if v, ok := p.conns.Load(address); ok {
228-
p.conns.Delete(address)
182+
if v, ok := p.conns.LoadAndDelete(address); ok {
229183
v.(*transports).close()
230184
}
231185
}
232186

233187
// Close is to release resource of ConnPool, it is executed when client is closed.
234188
func (p *connPool) Close() error {
189+
if !p.casClosed() {
190+
return nil
191+
}
192+
235193
p.conns.Range(func(addr, trans interface{}) bool {
236194
p.conns.Delete(addr)
237195
trans.(*transports).close()
@@ -240,6 +198,19 @@ func (p *connPool) Close() error {
240198
return nil
241199
}
242200

201+
func (p *connPool) isClosed() bool {
202+
return atomic.LoadInt32(&p.closed) == poolClosed
203+
}
204+
205+
func (p *connPool) casClosed() bool {
206+
return atomic.CompareAndSwapInt32(&p.closed, poolOpen, poolClosed)
207+
}
208+
209+
type dumpEntry struct {
210+
addr string
211+
tr grpc.ClientTransport
212+
}
213+
243214
// Dump dumps the connection pool with the details of the underlying transport.
244215
func (p *connPool) Dump() interface{} {
245216
defer func() {
@@ -251,32 +222,70 @@ func (p *connPool) Dump() interface{} {
251222
// remoteAddress -> []clientTransport, where each clientTransport is a connection. Distinguish the connection via localAddress.
252223
// If mesh egress is not enabled, toAddr should be the address of the callee service.
253224
// Otherwise, toAddr will be the same, so you should check the remoteAddress in each stream, which is read from the header.
225+
226+
// sync.Map does not expose its length directly.
227+
// Since dump is a cold-path operation, performance is not a major concern here
254228
poolDump := make(map[string]interface{}, p.size)
229+
var cliTransDumps []dumpEntry
255230
p.conns.Range(func(k, v interface{}) bool {
256231
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
232+
for _, tr := range v.(*transports).loadAll() {
233+
cliTransDumps = append(cliTransDumps, dumpEntry{addr: addr, tr: tr})
274234
}
275235
return true
276236
})
237+
238+
for _, cliTransDump := range cliTransDumps {
239+
dumper, ok := cliTransDump.tr.(interface{ Dump() interface{} })
240+
if !ok {
241+
continue
242+
}
243+
var curr []interface{}
244+
if poolDump[cliTransDump.addr] == nil {
245+
curr = make([]interface{}, 0)
246+
} else {
247+
curr = poolDump[cliTransDump.addr].([]interface{})
248+
}
249+
curr = append(curr, dumper.Dump())
250+
poolDump[cliTransDump.addr] = curr
251+
}
277252
return poolDump
278253
}
279254

255+
// newTransport creates a gRPC connection
256+
func newTransport(remoteService string,
257+
dialer remote.Dialer, network, address string, connectTimeout time.Duration, opts grpc.ConnectOptions,
258+
onGoAway func(context.Context, grpc.ClientTransport, grpc.GoAwayReason),
259+
onClose func(context.Context, grpc.ClientTransport, error),
260+
) (grpc.ClientTransport, error) {
261+
conn, err := dialer.DialTimeout(network, address, connectTimeout)
262+
if err != nil {
263+
return nil, err
264+
}
265+
if opts.TLSConfig != nil {
266+
tlsConn, tErr := newTLSConn(conn, opts.TLSConfig)
267+
if tErr != nil {
268+
// release tls handshake failed connection
269+
cErr := conn.Close()
270+
if cErr != nil {
271+
klog.Warnf("KITEX: Close TLS handshake failed connection, err: %v", cErr)
272+
}
273+
return nil, tErr
274+
}
275+
conn = tlsConn
276+
}
277+
return grpc.NewClientTransportWithConfig(
278+
context.Background(), // gRPC connection does not need to be bound to a specific ctx
279+
conn,
280+
opts,
281+
grpc.ClientConfig{
282+
RemoteService: remoteService,
283+
OnGoAway: onGoAway,
284+
OnClose: onClose,
285+
},
286+
)
287+
}
288+
280289
// newTLSConn constructs a client-side TLS connection and performs handshake.
281290
func newTLSConn(conn net.Conn, tlsCfg *tls.Config) (net.Conn, error) {
282291
tlsConn := tls.Client(conn, tlsCfg)
@@ -285,3 +294,12 @@ func newTLSConn(conn net.Conn, tlsCfg *tls.Config) (net.Conn, error) {
285294
}
286295
return tlsConn, nil
287296
}
297+
298+
func checkActive(trans grpc.ClientTransport) bool {
299+
if trans == nil {
300+
return false
301+
}
302+
// grpc.ClientTransport is implemented by *http2Client in pkg/remote/trans/nphttp2/grpc
303+
// it implements grpc.IsActive
304+
return trans.(grpc.IsActive).IsActive()
305+
}

0 commit comments

Comments
 (0)