在R语言中使用curl或httr发起HTTP请求时,函数本身会返回响应内容,代码看起来完全没有问题。但一个运行在服务器上的定时任务,每隔几分钟请求一批接口,几天后R进程的RSS内存从几百MB涨到几个GB,系统最终触发OOM。排查日志没有发现数据量异常增长,R自带的gc()也无法让内存回落。这种问题的根源往往不在R对象本身,而在curl连接的底层句柄没有被关闭。

一、问题复现:循环请求后RSS持续上涨
先用一段最简代码复现泄漏场景。下面这段循环中,每次请求都会调用curl::new_handle()创建一个新的连接句柄,请求完成后却没有执行close(h)。
library(curl)
fetch_all <- function(urls) {
lapply(urls, function(u) {
h <- curl::new_handle()
res <- curl_fetch_memory(u, handle = h)
# 注意:这里没有 close(h)
})
}
urls <- paste0("https://ipipp.com/api?page=", 1:200)
fetch_all(urls)
如果只运行几十次,内存变化可能不明显。把循环次数放大到几千次,或者把这个函数放入定时任务中反复执行,就能看到R进程的系统内存占用不断上升。可以用Linux下的命令查看进程内存和文件描述符数量:
ps -o rss= -p 12345 ls /proc/12345/fd | wc -l
其中RSS表示常驻内存,单位是KB。文件描述符数量如果也在同步上涨,说明连接并没有真正回到操作系统。即使手动调用gc(),R汇报的堆内存使用可能下降一点,但进程RSS和文件描述符几乎没有变化。原因在于gc()只管理R对象分配的内存,而curl句柄是在libcurl的C堆中分配的,属于R无法直接统计的外部资源。
这个现象很容易被误判成R的内存管理问题,实际是连接句柄未关闭导致的非托管内存泄漏。因为每个句柄内部都会维护接收缓冲区、请求头缓冲区、DNS缓存以及SSL会话信息,这些内存加在一起每次可能达到几KB到几百KB。请求量一大,时间一长,系统内存就会被耗尽。
二、为什么R的GC不能及时回收curl连接
R语言的对象回收机制主要针对向量、列表、环境等内建类型。curl包在创建句柄时,会返回一个外部指针对象,这个R对象本身非常小,真正的句柄数据存储在C层。curl包为外部指针注册了finalizer,理论上当R对象被垃圾回收时,finalizer会调用libcurl的清理函数释放底层内存。
但finalizer的触发时机完全由R的GC决定。R的GC根据已分配堆内存的阈值判断是否需要回收。在上述循环中,每次创建的外部指针对象只占用几十字节,R堆内存增长非常缓慢,GC可能很久才触发一次。与此同时,C层的curl句柄却在不断累积,每次几百KB甚至更大。R看不到这部分内存压力,自然也就不会主动触发GC来执行finalizer。即使GC最终执行,finalizer能否立刻释放所有句柄也受限于R对象是否仍然可达。
还有一种更隐蔽的情况:循环里使用同一个变量名接收new_handle()的返回值,例如h <- curl::new_handle()。前一个句柄的R对象确实会变成不可达,但R可能先不回收它,因为它对应的外部指针太小,GC没有动力。结果前一个句柄的C层资源继续占用内存和文件描述符。这种“小R对象拖累大C资源”的模式,正是R在网络编程和数据库连接等场景中容易踩的坑。
httr包底层同样依赖curl,如果返回的响应对象中保留了连接,而用户只提取了响应体,没有及时让响应对象离开作用域或主动关闭,资源释放时间同样不确定。因此不能把连接管理完全寄托在R的自动GC上。
三、定位手段与修复方案
要判断一个R进程是否存在连接未关闭导致的内存泄漏,最直接的指标不是R内部gc()的返回值,而是操作系统的进程监控数据。可以每隔一分钟记录一次RSS和文件描述符数量,如果两者都单调上涨并且手动调用gc()后不回落,基本可以认定存在外部句柄泄漏。文件描述符的增长尤其能说明问题,因为每个未关闭的TCP连接都会占用一个fd,当fd数量达到系统上限后,新请求会直接报错。
修复的核心原则是让连接句柄的生命周期与请求函数保持一致。第一个方案是使用on.exit()。它会在函数退出时自动执行关闭操作,无论函数正常返回还是中途报错。下面的写法比较稳妥:
fetch_page <- function(u) {
h <- curl::new_handle()
on.exit(close(h), add = TRUE)
res <- curl_fetch_memory(u, handle = h)
res
}
这里add = TRUE很关键,如果不加,on.exit()默认会覆盖前面注册的退出回调。把关闭动作放在创建句柄之后,可以保证无论后续代码成功还是失败,close(h)都会执行。对于使用curl::curl()打开连接的场景,同样需要在读取完成后关闭:
read_remote <- function(url) {
con <- curl::curl(url, "r")
on.exit(close(con))
readLines(con)
}
第二个方案是把请求封装成局部函数,将句柄创建和关闭限制在函数内部作用域。这样可以避免句柄被其他变量引用,减少不可达对象积压。第三个方案是在封装函数中加入句柄计数的全局变量,用来监控是否有遗漏。示例代码如下:
handle_count <- 0L
fetch_tracked <- function(u) {
h <- curl::new_handle()
handle_count <<- handle_count + 1L
on.exit({
close(h)
handle_count <<- handle_count - 1L
}, add = TRUE)
curl_fetch_memory(u, handle = h)
}
每次创建句柄时计数加一,关闭时计数减一。任务执行完成后检查handle_count是否归零,就能及时发现未关闭的连接。若计数不为零,说明某些路径漏掉了close(),例如错误处理分支没有经过on.exit(),或者存在循环提前跳出的情况。
生产环境中还可以把这些监控指标接入日志系统,每次请求批次结束后输出handle_count和当前RSS。如果RSS在正常业务波动之外持续上涨,同时句柄计数归零,则可能是其他非托管资源泄漏;如果句柄计数上升,则直接定位到curl连接未关闭。通过显式关闭和计数监控,可以避免R的GC与libcurl资源脱节带来的问题。