手上那个用 C 写的小工具,抓了几周数据一直挺稳,某天需求一变——出口要换成代理。很多人第一反应是去找一个类似 set_proxy() 的函数,翻遍头文件才发现:C 里压根没有这个东西。代理这件事没有全局开关,它必须落在 socket 这一层,你自己把连接过程拆开重接。听着吓人,实际改动量比想象中小,下面就把每一段代码摊开写。
先把链路画清楚,再动手改代码
没挂代理的时候,程序做的事很单纯:socket() 拿到一个句柄,connect() 连到目标服务器的 80 或 443,然后 send() 请求、recv() 应答。整条链路只有两段。
挂了代理之后就变成三段:你的程序连代理服务器,代理服务器再连目标站,数据在你和代理之间用同一条 TCP 通道来回搬。关键在于——连的目标地址变了,但后面的收发逻辑一行都不用改。这就是为什么改造的核心只有两个动作:换一个 connect 的地址,加一段"跟代理打招呼"的握手。
打招呼的方式取决于代理服务器说什么协议。市面上主流就两种:HTTP 代理和 SOCKS5 代理。前者用 CONNECT 方法开一条隧道,后者用一串二进制字节做握手。两种都写一遍,你对照自己的代理类型选。
HTTP 代理:用 CONNECT 打一条隧道
假设代理地址是 203.0.113.10:8080,要访问的目标是 example.com 的 443 端口。整个流程是:连上 203.0.113.10:8080,发一行 CONNECT 请求,代理回 200,从此这条 socket 就等同于直连目标站了。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
/* 连上代理并打通隧道,返回的 fd 之后当直连 socket 用 */
int tunnel_via_http_proxy(const char *proxy_ip, int proxy_port,
const char *target_host, int target_port)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return -1;
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(proxy_port);
inet_pton(AF_INET, proxy_ip, &addr.sin_addr);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
close(fd);
return -1;
}
/* 请求行里写 CONNECT,后面跟 host:port,不是网址 */
char req[512];
int n = snprintf(req, sizeof(req),
"CONNECT %s:%d HTTP/1.1\r\n"
"Host: %s:%d\r\n"
"Proxy-Connection: keep-alive\r\n"
"\r\n",
target_host, target_port, target_host, target_port);
if (send(fd, req, n, 0) != n) { close(fd); return -1; }
/* 只要状态行是 200,隧道就通了 */
char buf[1024];
int len = recv(fd, buf, sizeof(buf) - 1, 0);
if (len <= 0) { close(fd); return -1; }
buf[len] = '\0';
if (strstr(buf, " 200 ") == NULL) {
fprintf(stderr, "代理拒绝连接: %s\n", buf);
close(fd);
return -1;
}
return fd;
}
有个细节值得记一下:CONNECT 只用于加密流量(443)。如果你访问的是明文 HTTP,就不用开隧道,把请求行里的路径写成完整网址,比如 GET http://example.com/a.html HTTP/1.1,代理会自动转发。两种写法不要混,混了就会收到 400。
SOCKS5:多两次握手,代码不长
SOCKS5 的握手是二进制协议,看着吓人,其实就三步:协商认证方式 → 发连接请求 → 读应答跳过地址字段。真正麻烦的是那个"跳过地址字段",因为代理回的地址长度是可变的,写错了后面的数据就全乱。
#include <arpa/inet.h>
/* 一次读满 need 个字节,防止半包 */
static int recv_all(int fd, void *buf, size_t need)
{
size_t got = 0;
while (got < need) {
ssize_t n = recv(fd, (char *)buf + got, need - got, 0);
if (n <= 0) return -1; /* 0=对端关闭,-1=出错或超时 */
got += n;
}
return 0;
}
int tunnel_via_socks5(const char *proxy_ip, int proxy_port,
const char *target_host, int target_port)
{
int fd = tcp_connect(proxy_ip, proxy_port); /* 就是普通 connect */
if (fd < 0) return -1;
/* 第一步:我想用"免认证"方式 */
unsigned char hello[3] = { 0x05, 0x01, 0x00 };
if (send(fd, hello, 3, 0) != 3) { close(fd); return -1; }
unsigned char vr[2];
if (recv_all(fd, vr, 2) < 0) { close(fd); return -1; }
if (vr[0] != 0x05 || vr[1] != 0x00) { /* 0x02 表示要账号密码 */
close(fd); return -1;
}
/* 第二步:把域名丢给代理去解析 */
unsigned char req[300];
size_t hl = strlen(target_host);
int i = 0;
req[i++] = 0x05; /* 版本 */
req[i++] = 0x01; /* CONNECT 命令 */
req[i++] = 0x00; /* 保留位 */
req[i++] = 0x03; /* 地址类型:0x03 = 域名 */
req[i++] = (unsigned char)hl;
memcpy(req + i, target_host, hl); i += hl;
req[i++] = (unsigned char)(target_port >> 8);
req[i++] = (unsigned char)(target_port & 0xFF);
if (send(fd, req, i, 0) != i) { close(fd); return -1; }
/* 第三步:读应答,4 字节头之后要把地址+端口跳过去 */
unsigned char rep[4];
if (recv_all(fd, rep, 4) < 0) { close(fd); return -1; }
if (rep[1] != 0x00) { close(fd); return -1; } /* 非 0 就是失败码 */
int skip;
if (rep[3] == 0x01) skip = 4 + 2; /* IPv4 */
else if (rep[3] == 0x04) skip = 16 + 2; /* IPv6 */
else {
unsigned char dl;
if (recv_all(fd, &dl, 1) < 0) { close(fd); return -1; }
skip = dl + 2; /* 域名长度 + 端口 */
}
unsigned char sink[64];
while (skip > 0) {
int k = skip > (int)sizeof(sink) ? (int)sizeof(sink) : skip;
if (recv_all(fd, sink, k) < 0) { close(fd); return -1; }
skip -= k;
}
return fd; /* 通了,后面照常 send/recv */
}
这两段代码返回的都是一个普通的 fd,你原来的业务逻辑完全不用动。这也解释了一件事:换 IP 其实不用改业务代码,只要在建立连接的那一步把代理地址换掉,重跑一遍握手就行。
超时:三处不设,程序就卡在没人告诉你的地方
比代理改造更容易翻车的是超时。默认的 connect 在某些系统上能卡几十秒甚至几分钟,代理一挂,你整个线程池就废了。必须自己控制的有三个位置。
第一个是连代理。用非阻塞 connect 加 select 等待,超时直接放弃:
#include <fcntl.h>
#include <sys/select.h>
#include <errno.h>
/* 带超时的 connect,单位毫秒 */
int connect_timeout(int fd, const struct sockaddr *addr, socklen_t len, int ms)
{
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
int ret = connect(fd, addr, len);
if (ret < 0 && errno != EINPROGRESS) {
fcntl(fd, F_SETFL, flags);
return -1;
}
if (ret == 0) { /* 本机回环可能立刻连上 */
fcntl(fd, F_SETFL, flags);
return 0;
}
fd_set wset;
FD_ZERO(&wset);
FD_SET(fd, &wset);
struct timeval tv;
tv.tv_sec = ms / 1000;
tv.tv_usec = (ms % 1000) * 1000;
ret = select(fd + 1, NULL, &wset, NULL, &tv);
if (ret <= 0) { /* 0=超时,-1=出错 */
fcntl(fd, F_SETFL, flags);
close(fd);
return -1;
}
int err = 0;
socklen_t elen = sizeof(err);
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &elen); /* 必须查这一步 */
fcntl(fd, F_SETFL, flags); /* 记得切回阻塞模式 */
if (err != 0) { close(fd); return -1; }
return 0;
}
注意 getsockopt 那一行,很多人 select 返回可写就当连上了,其实连接失败也会变成"可写",不查 SO_ERROR 会把错误状态当成成功。
第二个是握手。代理回包慢是常态,尤其是隧道刚刚建立的时候。用 SO_RCVTIMEO 卡住就行,简单粗暴:
struct timeval tv = { 5, 0 }; /* 5 秒 */
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
设完之后 recv 超时会返回 -1,errno 是 EAGAIN(有些平台是 EWOULDBLOCK),别当成致命错误直接退出,把它识别成"这次代理不给力",换一个 IP 重来更重要。
第三个是读写本身。这条链路是拿来做数据交换的,有些代理会保持连接但迟迟不回内容,这种情况 SO_RCVTIMEO 就是最后一根救命绳。
| 超时位置 | 建议值 | 不设的后果 |
|---|---|---|
| 连接代理 | 3 ~ 5 秒 | 代理不可用时任务长时间挂起 |
| 隧道 / 握手应答 | 5 秒 | 代理限速时无限期等待 |
| 单次 recv / send | 8 ~ 15 秒 | 半死连接一直占着线程 |
| 整个任务总时长 | 30 秒左右 | 个别慢请求拖垮整批任务 |
几个真踩过的坑,提前说一声
域名到底谁去解析。上面两段代码都是把 host 字符串直接交给代理,本机不做 DNS。好处是解析发生在代理侧,出口和解析在同一个地方,不会出现解析到本地地址的尴尬。如果你图省事先把域名解析成 IP 再拼请求,碰到有地区差异的站点就会出问题——解析结果和出口不在一个城市。
recv 一次读不完。隧道握手回包可能被拆成两段到达,尤其是走长途链路的时候。凡是"协议规定长度"的地方,一律用 recv_all 循环读满,别用一个 recv 就往下走。
代理要认证的时候。HTTP 代理会回 407,需要加一行 Proxy-Authorization: Basic base64(用户名:密码);SOCKS5 则会回 0x02,说明要账号密码,得先补一轮 0x01、长度、账号、长度、密码的字节,再重新发请求。神龙HTTP 的代理支持免认证直接连,代码里就可以省掉这一整块。
长连接会断。代理侧一般有过空闲踢人的策略,你的 fd 看着还活着,实际早就被回收了。读的时候 recv 返回 0 就说明对端关了,别当正常数据,老老实实重建连接。
出错路径别忘了 close。上面每个 return -1 前面都跟了 close,这是有意的。一次跑几千个请求,漏掉十几处 close,进程的文件句柄数很快就顶到上限,报错信息还是看着毫不相干的 EMFILE。
IP 从哪来:把取 IP 这一步也写成代码
代码改好了,接下来是货源问题。自己维护代理池是最费神的一段,IP 掉线、协议不匹配、城市对不上,任何一个都会让上面那套逻辑白写。
我这边的做法是直接从神龙HTTP 的接口取。它的返回是纯文本,一行一个 ip:port,C 里读到缓冲区之后按换行切一刀就能塞进数组,连 JSON 解析库都不用引。
/* 从接口拿到的文本长这样:
* 203.0.113.10:8080
* 203.0.113.25:8080
* 按 '\n' 切开存进数组即可
*/
char *line = strtok(buf, "\r\n");
while (line != NULL) {
/* 解析成 proxy_ip / proxy_port 交给 tunnel_via_socks5 */
line = strtok(NULL, "\r\n");
}
选它主要是两个原因对上我的需求。一是协议齐全,HTTP/HTTPS/SOCKS5 都支持,上面两种握手代码写哪种都能用,短效动态、长效静态、固定IP 三类产品在一套接口里切换,不用为不同场景改两次代码。二是精度够用,300+ 城市级精准定位配合 3000万+ 正规授权代理IP储备,做市场研究时想指定某个城市取样,直接按城市参数取就行,可用率标称 99.9%、IP 纯度 99.8%,实际跑下来重试率很低。
另外个人中心里有可视化的用量统计和实时监控,哪个时间段失败率上去了,曲线上一眼能看到,比自己打日志慢慢翻强。技术团队 7×24 小时在线,半夜被 407 卡住也有人问。
常见问题
Q:代码跑起来 recv 一直返回 -1,errno 是 EAGAIN,是代理坏了吗?
大概率不是。EAGAIN 说明你设了 SO_RCVTIMEO 且超时了。先确认握手阶段是不是已经读到 200 或 0x00,再看代理是不是在限速。可以临时把 tv_sec 调到 20 秒复测一次,如果变慢但能回,那就是代理侧响应慢,换一个 IP 就行。
Q:原来代码里用的是域名,改成代理之后必须先用 gethostbyname 吗?
不用,而且不建议。CONNECT 请求行和 SOCKS5 的 0x03 地址类型都能直接塞域名字符串,让代理服务器去解析。这样省掉一次本机 DNS 查询,也避免解析位置和出口位置不一致的问题。
Q:HTTP 代理和 SOCKS5 改造量差多少?
SOCKS5 多写二十来行,主要是三段 recv_all 和一个跳地址字段的循环,逻辑是固定的,抄一遍改改就能用。HTTP 代理更简单,一行 snprintf 加一次字符串判断。如果你的代理同时支持两种,建议统一用 SOCKS5,少了明文头部的拼接,出问题也好排查。
Q:一次跑几百个连接,怎么保证不把代理打挂?
三件事:一是每个连接都设好上面那三处超时,别让死连接拖住队列;二是失败要能重取 IP 重试,别在一个失效 IP 上反复连;三是尽量复用已经建好的隧道,用完再关,不要每个请求都重新握手。神龙HTTP 支持高并发提取,短效动态和长效静态可以按任务类型分开用,短任务用短效、长会话用静态,重试率能降不少。






